DISABLE_REGISTRATION = true. You create accounts; nobody self-serves. If you need occasional sign-ups, use the invitation flow rather than opening the door.

Your own Git server.
The repository is the org chart.
A private repository is a dated record of what you are building, who you are building it with, and how far along you are — and on a platform, that record is held by a company in someone else's jurisdiction. Six steps to move it onto a box you rent anonymously: Forgejo behind Caddy, PostgreSQL underneath, SSH push on a port of its own, an Actions runner that never sees your production secrets, and a backup you have actually restored. The box costs $3.90 a month. Tested on Debian 12.
- 01
Choose
Bare repo, Forgejo, GitLab
- 02
Provision
Ports decided before boot
- 03
Deploy
Forgejo, Postgres, Caddy
- 04
Push
SSH, deploy keys, signatures
- 05
Build
A runner, kept at a distance
- 06
Keep
Mirrors, backups, upgrades
The code is the least interesting part. The metadata is the asset.
Almost every argument about self-hosting Git is an argument about the source code, and the source code is the part that matters least. Most of it is unremarkable, a great deal of it is a thin layer over open-source libraries, and if it leaked tomorrow the practical damage would usually be embarrassment rather than harm. That is why "my code is not secret" feels like a complete answer, and why it is not one.
What surrounds the code is the interesting part. A repository is a dated, attributed, high-resolution record of work: commit timestamps that describe when you sleep, co-author lines that describe who you work with, branch names that describe products nobody has announced, an issue tracker that describes what is broken and how badly, and a first commit that dates the whole project. Read a hundred repositories that way and you are not reading software any more — you are reading a company, its roadmap, its headcount and its trouble.
On a platform, that record is not yours in any meaningful sense. It sits on infrastructure you do not control, under an account tied to an identity you verified, in a jurisdiction whose legal process reaches it, scanned by systems whose scope is set by a document that can be amended. None of that requires bad faith from anyone: it is just what renting the tracker means. Four arrangements, and what each one actually gives away:
| Where the repository lives | Who holds the history | Tied to your legal identity | How it goes wrong |
|---|---|---|---|
| A platform, personal account | The platform, in its own jurisdiction | Yes — email, billing, often a phone number | One suspension takes every remote with it |
| A platform, organisation account | The platform, plus whoever administers the org | Yes, and to your employer as well | Offboarding removes your access to your own work |
| Self-hosted, on a hyperscaler | You, at the application layer | Yes — the billing account is the identity | The snapshot lives in the account that can be closed |
| Self-hosted, on a KYC-free VPS | You, and only you | No — no ID, no card, crypto-settled | You are the sysadmin now. A bad backup is final |
Row three is the row that catches teams who think they have already solved this. A forge you installed yourself, on a machine rented with a corporate card under a verified company account, has moved the application out of the platform and left the identity exactly where it was. That is a real gain in control and no gain at all in exposure.
And row four states its own cost plainly, because the rest of this guide is mostly about paying it. The failure mode of self-hosting is not surveillance, it is loss. A platform keeps backups you never think about. Your server keeps the backups you configured. Step 06 is not the optional polish at the end — it is the step that makes the other five safe.
How much forge do you need? The honest answer is often "none".
Start with the smallest thing that solves your problem, because every layer above it is a service you have to operate. A bare repository over SSH is one command on a server you already have, and it gives you a real Git remote with authentication, history and push access. No web application, no database, no upgrade treadmill, nothing to patch beyond sshd. If you are one person who wants your commits off a laptop and out of a platform, this is a complete answer and you can stop reading after step 02.
# On the server, as an unprivileged user:
mkdir -p ~/git/project.git && cd ~/git/project.git
git init --bare
# On your laptop:
git remote add origin ssh://user@git.example.com/~/git/project.git
git push -u origin main
You need a forge when a second person is involved, or when you want any of the four things a directory of bare repositories cannot do: browse the history in a browser, review changes as pull requests, track issues next to the code, and grant one person access to one repository. That is a genuine step up in capability and a genuine step up in operational weight. Choose deliberately:
| Property | Bare repo over SSH | Forgejo | Gitea | GitLab CE |
|---|---|---|---|---|
| Web UI, pull requests, issues | None | Yes | Yes | Yes, and far more |
| Memory at rest | Effectively zero | A few hundred MB | A few hundred MB | Several GB, before CI |
| Built-in CI | git hooks, if you write them | Forgejo Actions | Gitea Actions | GitLab CI, the mature one |
| Who steers the project | The Git project | Codeberg e.V., a non-profit | A for-profit company, open core | A listed company, open core |
| Licence | GPLv2 | GPLv3+ since v9.0 (MIT before) | MIT, with proprietary add-ons | MIT core, proprietary tiers |
| Pick it when | You are alone and want zero maintenance | You want a real forge on a small box | You already run it and it works | You need the enterprise feature set |
Forgejo and Gitea share an ancestor and still look almost identical in daily use. Forgejo became a hard fork in early 2024 and is held by Codeberg e.V., a non-profit registered association in Berlin; Gitea is developed by a for-profit company that owns its trademark and asks contributors for copyright assignment. Both are good software. Only one of them has a custodian whose incentives match the reason you are reading this page.
The rest of this guide uses Forgejo. Everything in steps 02 to 06 applies almost verbatim to Gitea — the container name and a few environment variables change, the shape does not.
Decide the ports before the forge decides them for you. Two of them are SSH.
Order the box first. In the panel: Order → VPS → Sentinel, image Debian 12, in whichever bastion you want the history to live in — Helsinki, Stockholm, Oslo or Reykjavík. No email address is required to open the account and the invoice settles in Monero, Bitcoin, Lightning or any other supported asset. That matters more here than for most workloads: a forge is the one service whose entire value proposition is that the record of your work is not attached to a name.
Then run the first-hour hardening checklist — key-only SSH on a moved port, unattended upgrades, a default-deny firewall — because everything below assumes a box that is not already someone else's.
A forge is not a static site with a login. It is a public web application with a code-execution engine bolted to it. Two defaults, left alone, turn it into a free service for strangers: open registration, which fills the instance with spam accounts within days of the certificate appearing in the transparency logs, and CI that runs workflows from anyone who can open a pull request, which is how a self-hosted runner ends up mining cryptocurrency for somebody else. Neither is a subtle attack and neither is rare. Both are closed by one setting each, in step 03 and step 05 — but decide now that you are going to close them, because the window between "it works" and "it is being used" is measured in days.
Point a hostname at the box — an A record, and an AAAA if you want it reachable over IPv6 — and let it propagate before you start, because the reverse proxy will try to obtain a certificate for that name the first time it boots and a failed challenge is a confusing way to begin. Then write the firewall rules while nothing is listening yet.
The two-SSH problem. A forge speaks SSH for Git, and your server already speaks SSH for administration. They cannot both own port 22, and mixing them is how people end up with an administrative daemon that also has to understand Git. Keep them separate: administration on the moved port from the hardening guide, Git on a dedicated public port that maps into the container. 2222 is the conventional choice and there is nothing sacred about it.
ufw default deny incoming
ufw allow 47222/tcp # administrative SSH, moved
ufw allow 80/tcp # ACME HTTP-01 challenge
ufw allow 443/tcp # the web interface
ufw allow 2222/tcp # git over SSH, into the container
ufw enable
ufw status numbered
# Nothing else is open. In particular: PostgreSQL stays on the Docker
# network and is never published to the host, let alone the internet.
Install Docker from the official repository rather than the distribution package, so that Compose v2 is present and the version is current. Then create a directory for the stack and make it the only place on the box that holds state — it is the directory step 06 backs up.
curl -fsSL https://get.docker.com | sh
docker compose version
install -d -m 0750 /srv/forgejo
cd /srv/forgejo
Three containers. One of them must never be reachable.
Forgejo, PostgreSQL and Caddy. Forgejo holds the repositories and serves the interface, PostgreSQL holds everything that is not a Git object, and Caddy terminates TLS and obtains the certificate without being asked twice. PostgreSQL is deliberately not published to the host: it is reachable only over the Compose network, by name, from the one container that needs it.
/srv/forgejo/.env
FORGEJO_DOMAIN=git.example.com
FORGEJO_VERSION=15 # the LTS line; 16 is current stable
POSTGRES_PASSWORD=$(openssl rand -base64 32) # paste the value, do not leave the $( )
/srv/forgejo/docker-compose.yml
services:
forgejo:
image: codeberg.org/forgejo/forgejo:${FORGEJO_VERSION}
container_name: forgejo
environment:
USER_UID: 1000
USER_GID: 1000
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: db:5432
FORGEJO__database__NAME: forgejo
FORGEJO__database__USER: forgejo
FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD}
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN}
FORGEJO__server__ROOT_URL: https://${FORGEJO_DOMAIN}/
FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN}
FORGEJO__server__SSH_PORT: "2222" # what clones will say
FORGEJO__server__SSH_LISTEN_PORT: "22" # what the container listens on
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__repository__DEFAULT_PRIVATE: private
FORGEJO__security__INSTALL_LOCK: "true"
restart: unless-stopped
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000" # web, reached only by Caddy
- "2222:22" # git over SSH, public
depends_on:
- db
db:
image: postgres:16-alpine
container_name: forgejo-db
environment:
POSTGRES_USER: forgejo
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: forgejo
restart: unless-stopped
volumes:
- ./postgres:/var/lib/postgresql/data
# No ports: block. This is the point.
caddy:
image: caddy:2-alpine
container_name: forgejo-caddy
restart: unless-stopped
network_mode: host
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
/srv/forgejo/Caddyfile
git.example.com {
encode zstd gzip
request_body {
max_size 512MB # raise it if you push large repositories
}
reverse_proxy 127.0.0.1:3000
}
The two SSH lines are the ones people get wrong, so they are worth reading twice. SSH_LISTEN_PORT is the port inside the container, where Forgejo's built-in SSH server binds. SSH_PORT is the port Forgejo prints in the clone URLs it shows you — it must match the public side of the port mapping. Get them out of step and everything works except the one thing users copy and paste.
Bring it up, then create the administrator from the command line. Do not use the web installer. On a fresh instance it is reachable by anyone who finds the hostname, and the first account created through it becomes the administrator — a race you do not need to enter. INSTALL_LOCK in the file above closes it before the container ever starts.
docker compose up -d
docker compose logs -f forgejo # wait for "Starting new Web server"
docker compose exec -u git forgejo forgejo admin user create \
--admin --username you --email you@example.com --random-password
Open the hostname in a browser, sign in with the printed password and change it. Then walk through the settings that turn a working instance into a closed one — most are already set by the environment block above, and it is worth confirming each of them rather than assuming:
DEFAULT_PRIVATE = private. New repositories start closed. Publishing is then a deliberate act rather than the consequence of a default.
REQUIRE_SIGNIN_VIEW = true hides even public repositories from logged-out visitors. Right for a private team forge; wrong if you want to publish anything.
Enable it on the administrator account before you do anything else. The forge is now the machine that can rewrite every branch you have.
One path worth memorising: the live configuration is at ./data/gitea/conf/app.ini on the host. Forgejo kept the gitea directory name for compatibility with existing installations, which surprises everyone exactly once. Environment variables of the form FORGEJO__section__KEY are written into that file on start, so you can manage the whole configuration from the Compose file — but if you edit app.ini by hand, an environment variable for the same key will overwrite your edit on the next restart. Pick one of the two and be consistent.
Push over SSH. Then sign it, so the history does not depend on the server.
Paste your public key into the profile settings, then teach your client where the forge lives. Because Git is on a non-standard port, an entry in your SSH config is worth the thirty seconds — it keeps the port out of every URL you will ever type and makes the remotes portable.
# ~/.ssh/config
Host git.example.com
Port 2222
User git
IdentityFile ~/.ssh/id_ed25519
# Then, in any repository:
git remote set-url origin git@git.example.com:you/project.git
git push -u origin main
ssh -T git@git.example.com # expect a greeting, not a shell
That last command is the one that tells you whether the port mapping, the container SSH server and your key all agree. A greeting from Forgejo means the whole chain works. A password prompt means you have reached the host's administrative sshd instead, and the port mapping is wrong.
Now sign your commits. This is the step that most self-hosting guides skip, and on a self-hosted forge it matters more than it does on a platform. A commit's author field is free text — anyone who can push can write your name into it. A signature is proof that a specific key made a specific commit, and it keeps being proof after the server it was pushed to has been compromised, migrated or thrown away. Git can sign with the SSH key you already have, so there is no PGP detour any more:
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
git commit --allow-empty -m "signed"
git log --show-signature -1
Then add the same public key a second time in the forge, as a signing key rather than an authentication key. Forgejo verifies the signature server-side and shows the commit as verified; without that second entry it has no way to connect the signature to your account, and every commit shows as unverified even though it is signed correctly.
Deploy keys, for the machines that only read. A production server, a build box or a mirror does not need a user account on the forge. Give it a deploy key instead: a key pair added to one repository, read-only unless you explicitly grant write, granting access to nothing else on the instance. Revoking it is deleting one entry. This is the single most effective habit for keeping a self-hosted forge tidy, because user accounts accumulate permissions and deploy keys cannot.
Migrating from a platform is a good moment to do all of this properly. Forgejo's New Migration flow takes a repository URL and an access token and brings across branches, tags, issues, comments, labels, milestones, releases and pull requests. Run one repository first to see the shape of the result, expect API rate limits to slow large batches, and keep the source read-only for a few weeks rather than deleting it the same day.
A CI runner is a machine that runs strangers' code. Place it accordingly.
Forgejo Actions reads workflow files in the same format as GitHub Actions, from .forgejo/workflows/, and hands them to a separate program called the Forgejo Runner. The runner is not part of the forge and does not have to live on the same machine — which is the whole design, and the reason this step comes with a warning rather than a shrug.
Enable Actions on the instance, then get a registration token from the administration panel — instance-wide, or scoped to one organisation or one repository, which is the better default when you are not sure. The runner connects outward to the forge, so it needs no inbound ports at all and can sit behind any firewall you like, including on a machine with no public address.
# In the Compose environment of the forge, or app.ini:
# FORGEJO__actions__ENABLED: "true"
# Then: Site administration -> Actions -> Runners -> Create new runner
# On the runner machine:
forgejo-runner generate-config > config.yml
forgejo-runner register --no-interactive \
--instance https://git.example.com \
--token <REGISTRATION_TOKEN> \
--name runner-01 \
--labels docker:docker://node:22-bookworm,debian:docker://debian:12
forgejo-runner --config config.yml daemon
The labels are the contract between the workflow and the runner: a job that asks for runs-on: docker gets a container built from the image on the right-hand side. Declare only the labels you are actually prepared to serve, because a job asking for a label nobody offers waits forever rather than failing, which is a confusing thing to debug at the end of a long day.
.forgejo/workflows/ci.yml
on: [push, pull_request]
jobs:
test:
runs-on: docker
container:
image: node:22-bookworm
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
A runner executes whatever the workflow file says, and the workflow file arrives in a pull request. If anyone outside your team can open one, the runner belongs on a different machine from the forge — a second Sentinel at $3.90 a month, with no access to the forge's data directory, no database credentials and nothing in its environment you would mind reading in a build log. On the forge itself, require approval before running workflows for first-time contributors. The two standard failures are both boring and both expensive: a mining workload buried in a pull request, and a secret printed by a step somebody added deliberately.
Scope the secrets the same way. Forgejo stores them at repository, organisation and instance level; a deployment key that only one repository needs should exist only on that repository, so that a compromised workflow in an unrelated project cannot read it. Prefer short-lived tokens over long-lived ones wherever the target supports it, and never put a secret in a workflow file — the workflow file is in the repository, and the repository has a history.
If your builds pull the same base images on every run, cache them. A local registry mirror or a persistent runner cache turns a two-minute image pull into a two-second one, and it is the polite thing to do to the upstream registries you are hammering. And note the pattern the whole design invites, which is the reason this guide sits next to the agent runtime guide: the runner is a general-purpose worker that pulls jobs from your forge, so it is also where scheduled maintenance, data pipelines and agent workloads belong.
Every clone is a backup of the code. Nothing is a backup of the conversation.
Git is unusually forgiving here and it is worth being precise about why. Every clone is a complete copy of the history, so as long as one developer has a current working copy, the code survives the server. That is genuinely reassuring, and it covers exactly one of the two things your forge holds. The other — issues, pull request discussions, review threads, releases, wiki pages, users, teams, permissions, CI history — exists only in the database, nobody has a clone of it, and it is usually the part that took three years to accumulate.
First, a push mirror. It is free and it runs itself. In a repository: Settings → Mirror settings → add a push mirror with the URL and an access token for the destination. Forgejo then replicates every branch and tag after each push, to a second Forgejo, to Codeberg, or to a platform you have decided to keep as a shop window rather than a home. One caveat worth stating plainly: a push mirror republishes history faithfully, including a secret you meant to remove.
Then a real backup, which is not the built-in archive. Forgejo ships a forgejo dump command that packs configuration, repositories, data and a database dump into a single zip. It is a genuinely useful thing to hand a maintainer when reporting a bug. It is not what you should be relying on to come back from the dead: the SQL it produces has long-standing known issues on re-import, and the archive rewrites the whole repository set every run, which on a real instance means a large file written from scratch every night. Use the boring, verifiable path instead — dump the database with the database's own tool, and back up the data directory as files:
#!/bin/sh
# /usr/local/bin/forgejo-backup — run from a systemd timer, nightly
set -eu
cd /srv/forgejo
# 1. Consistent database dump, written next to the data.
docker compose exec -T db pg_dump -U forgejo -Fc forgejo > ./db.dump
# 2. Everything that matters, to an encrypted offsite repository.
restic backup ./data ./db.dump ./docker-compose.yml ./.env ./Caddyfile
# 3. Retention, then prove the repository is still readable.
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
restic check
Send it to a machine in a different jurisdiction from the forge, over an append-only repository so that a compromise of the forge cannot erase its own history — the encrypted backups guide is the long version of the three lines above, including the systemd timer and the append-only key restriction.
Then restore it once, on purpose. Order a scratch VPS, restore the snapshot into it, bring the same Compose file up against the restored data directory, load the database dump, point a hosts-file entry at it and sign in. Time the whole thing. That number — not the existence of the backup job — is your actual recovery position, and everybody who has done this exercise has found at least one thing missing from the include list the first time.
# restic keeps absolute paths, so the stack reappears under the target.
restic restore latest --target /srv/restore
cd /srv/restore/srv/forgejo
docker compose up -d db # empty volume: the entrypoint creates the database
docker compose exec -T db pg_restore -U forgejo -d forgejo \
--clean --if-exists --no-owner < db.dump
docker compose up -d # then sign in and walk the issue tracker
Upgrades, briefly. Pin the image to a major version rather than :latest, so an unattended pull cannot move you across a breaking change while you sleep. Read the release notes before crossing a major boundary, take a snapshot first, and remember that a database migration runs on the first start of the new version — which is a one-way door. Watchtower-style auto-updating is a fine idea for a static site and a poor one for the machine that holds every branch you have.
Four checks before you call it done. Sign out and confirm the instance offers no way to create an account. From a browser with no session, confirm a private repository is invisible rather than merely forbidden. Confirm the runner cannot read the forge's data directory. And confirm the last restic check passed — the backup job that fails silently is the one everybody has, right up until the day they need it.
You removed one party. You did not remove the building.
Self-hosting a forge is a real change in who holds your work, and it is not invisibility. Four things remain true after everything above is done, and knowing them is the difference between a defence and a story you tell yourself.
The disk is in somebody's building. A rented server runs on hardware in a datacentre with doors and staff. NordBastion does not inspect customer workloads, and the transparency report and warrant canary describe what would happen if we were asked — but the architectural statement is the honest one: what changes is that there is no product-level index of your repositories, no analytics pipeline over your commits and no privacy policy that can be amended. What does not change is that the machine is physical and it is not in your house.
The hostname becomes public the moment the certificate is issued. Every publicly trusted certificate is published in certificate-transparency logs that anyone can search, so git.yourproject.com is discoverable even if you never link to it. That is not a leak of your code, but it is a leak of the fact that the project exists and roughly when it started. Use a subdomain of a name you already publish, or skip the public certificate entirely and reach the forge over WireGuard or an onion service.
You are now patching a web application. The forge is a login page, a rendering pipeline for untrusted markup, an SSH server and a CI engine, all reachable from the internet. That is a considerably larger surface than the bare repository from step 01, and it is one you have to keep current. Subscribe to the release announcements, and treat a security release as a same-week job rather than a same-quarter one.
Federation is not here yet. Forgejo is doing real work on forge federation and publishes progress on it, but as of today you cannot open a pull request on somebody else's instance from your own the way you can on a shared platform. Practically, that means an external contributor still needs an account on your forge, or you accept patches by email, or you keep a public mirror for drive-by contributions. Plan for whichever of the three matches your project rather than being surprised by it.
What you have actually bought, precisely: the dated record of what you build, with whom, and when, is no longer held by a company whose business model includes analysing it. That is a narrow claim, and it is a real one. It is worth what it costs only if step 06 is done — a self-hosted forge without a tested restore is not a privacy improvement, it is a single point of failure with better branding.
The forge is small. The CI is not.
| Tier | Monthly | Resources | What it comfortably runs |
|---|---|---|---|
| Sentinel | $3.90 | 2 vCPU · 4 GB · 120 GB | Forgejo + PostgreSQL for one person or a small team. The right start. |
| A second Sentinel | $3.90 | The runner, on its own box | The isolation that actually matters, for the price of a coffee |
| Garrison | $7.90 | 4 vCPU · 8 GB · 240 GB | A ten-person forge, a container registry and artifacts |
| Ravelin | $16.90 | 8 vCPU · 16 GB · 480 GB | Busy CI, large repositories, several parallel jobs |
Nothing in this table is sized for Git. Forgejo and PostgreSQL together idle in a few hundred megabytes; what fills a box is CI — container layers, build caches, artifacts — and repository content that is not source code. Size for the disk first, the cores second, and the memory last. Two small boxes beat one large one here, because the second one is a security boundary rather than capacity.
Six ways this goes wrong. Two of them cost money.
Open registration, and a forge full of strangers
The certificate appears in the transparency logs, the scanners follow within days, and an instance with sign-ups enabled fills with spam accounts and spam repositories. Set DISABLE_REGISTRATION before the first boot, not after the first incident.
The runner that mines cryptocurrency for somebody else
CI that runs workflows from any pull request is free compute for whoever finds it. Require approval for first-time contributors, keep the runner on its own box, and read the job durations occasionally.
ROOT_URL wrong, and everything half-works
Clone URLs point at the wrong host, webhooks fire at the wrong address, OAuth callbacks fail and avatars break. The interface looks fine, so nobody suspects one line. Set DOMAIN, ROOT_URL, SSH_DOMAIN and SSH_PORT together and check a clone URL in the UI.
Two SSH servers, one port
The container wants 22 and the host is already using it, so the mapping silently lands somewhere unexpected and git push asks for a password. Move administrative SSH first, publish the container on a port of its own, and verify with ssh -T.
A dump that has never been restored
The built-in archive is a support artefact, not a tested recovery path, and its SQL has known re-import problems. Dump the database with pg_dump, back up the data directory as files, and restore the whole thing onto a scratch box once.
The disk fills with build layers, not with code
Source code is tiny; container layers, artifacts and CI caches are not. A 120 GB box with an enthusiastic pipeline reaches full in weeks. Prune images on a timer, cap artifact retention, and watch the disk rather than the CPU.
Questions, answered.
Eleven questions that come up while building this — starting with the two that decide whether you should build it at all.
Forgejo or Gitea — which one should I actually install?
Forgejo, for most people, and the reason is governance rather than features. The two projects share an ancestor and still feel almost identical to use: same Go binary, same lightweight footprint, same web interface, comparable CI. What differs is who steers them. Forgejo is held by Codeberg e.V., a non-profit registered association in Berlin, and became a hard fork in early 2024; it publishes everything as free software and does not ask contributors to assign copyright. Gitea is developed by a for-profit company that owns the trademark and the domain, requires copyright assignment from contributors, and runs an open-core model with paid features around the free base. Neither of those facts breaks Gitea as software. But the whole point of moving your repositories off a platform is to stop depending on a company's roadmap, so picking the fork whose custodian is a non-profit is the choice that matches the reason you are here. One practical note if you are switching later: Forgejo can be installed over an existing Gitea data directory, and going the other way is not supported.
Do I even need a forge? Is a bare repository over SSH not enough?
For one person with one laptop and a habit of writing good commit messages, a bare repository is genuinely enough, and it is the most robust thing in this guide: git init --bare on the server, a normal SSH account, and there is no web application to patch, no database to back up and no attack surface beyond sshd. Nothing about that setup will ever be end-of-life. You need a forge the moment a second human is involved, or the moment you want any of the four things a bare repository has no idea about — a browsable history, pull requests with review, an issue tracker that lives next to the code, and per-repository access control. That is not a small step up; it is the difference between storage and collaboration. Be honest about which one you are solving for, because a forge is a service you now operate, and a bare repository is a directory.
Can I migrate my GitHub repositories, including issues and pull requests?
Yes, and it is one of the better migration stories in self-hosting. Forgejo's built-in migration tool takes a repository URL and a personal access token and pulls far more than the code: branches and tags, issues with their comments, pull requests, labels, milestones and releases. Give it a token with read scope, run one repository first to see what the result looks like, then work through the rest. Three things to expect. Large or numerous migrations will hit the source platform's API rate limits and simply take longer — start them and leave them. Attachments on issues are fetched over the network, so an instance behind a restrictive egress firewall will produce issues with broken images. And review threads do not always land exactly where they were, because the two data models are not identical. Plan on keeping the source repositories read-only for a few weeks rather than deleting them the same afternoon.
Is a self-hosted forge really more private, or only different?
It is genuinely different in one specific and important way: nobody but you holds the index. On a platform, the private repository is private to other users — it is not private to the platform, which stores it, scans it, computes a dependency graph from it, and can be compelled to produce it by a court in its own jurisdiction. That index is unusually revealing even when the code is dull: commit timestamps describe your working hours, co-authors describe your team, branch names describe unreleased products, and the first commit dates a project that has not been announced. Moving it to your own server removes the platform from that picture entirely. What it does not do is make the machine disappear. You are still renting hardware in a datacentre, the disk is still physically reachable by the people who own the building, and the hostname you chose is still in certificate-transparency logs. The honest summary: you have replaced a company with a contractual privacy policy by a server whose only policy is your configuration, in a jurisdiction you picked deliberately — and the account that pays for it does not have to carry your name.
How big does the VPS need to be?
Smaller than the GitLab documentation has trained everyone to expect. Forgejo and Gitea are single Go binaries that idle at a couple of hundred megabytes of RAM; add PostgreSQL and a reverse proxy and a solo developer's forge sits comfortably in a Sentinel — 2 vCPU, 4 GB, 120 GB NVMe, $3.90 a month — with room to spare. The pressure comes from three other places, and each of them is worth sizing for deliberately. CI is the big one: a runner building containers will use every core you give it and fill the disk with layers. Repository size is the second: game assets, notebooks and vendored dependencies grow far faster than source code. Artifacts and a container registry are the third. A rule that has held up well in practice — put the forge on a Sentinel, put the runner on its own second Sentinel, and only move up a tier when the disk tells you to rather than when the CPU does.
Is Forgejo Actions really compatible with my GitHub Actions workflows?
Compatible enough that most straightforward workflows run unchanged, and not so compatible that you should migrate a complex pipeline without reading it. The workflow file format is the same, it lives in .forgejo/workflows/ (and .github/workflows/ is also read), and the common building blocks — checkout, language setup, cache, matrix builds — behave as you would expect because the runner executes them in containers the same way. What breaks is anything that assumes GitHub the platform rather than Actions the format: marketplace actions pinned to github.com URLs that your instance has to be allowed to fetch, actions that call the GitHub REST API for release or PR metadata, GitHub-hosted runner images with a very specific pre-installed toolchain, and OIDC-based cloud authentication. The migration pattern that works: start with a workflow that only builds and tests, get that green, then re-add the deployment steps one at a time.
What happens to my code if the VPS dies?
The code itself is the one part that is fine, and this is the quiet advantage of Git over almost every other self-hosted service: every clone is a complete copy of the history, so as long as one developer has a current working copy, the repository is not lost. What dies with the server is everything around the code — issues, pull request discussions, review comments, releases, wiki, users, access rules, CI history. That material lives only in the database, nobody has a clone of it, and it is usually the part that took years to accumulate. So treat the two differently. For the code, configure a push mirror to a second location and you have continuous, automatic, offsite protection at no effort. For the metadata, you need a real backup: a PostgreSQL dump plus the data directory, encrypted and sent to a machine in another jurisdiction on a timer. Step 06 covers both, and the restore drill at the end is the part that turns them from an intention into a guarantee.
Can I keep GitHub as a mirror rather than leaving it entirely?
Yes, and for a lot of projects that is the right answer rather than a compromise. Forgejo supports push mirrors, so your instance is the origin your team works against and every push is replicated automatically to a repository on GitHub, Codeberg or a second Forgejo. Public visibility, drive-by contributors, package registries that expect a GitHub URL and the issue-tracker link in an old README all keep working, while the source of truth — and the private repositories, and the CI logs, and the issue history of anything you do not publish — stays on your box. It also works in the other direction: pull mirrors keep a local, always-current copy of upstream projects you depend on, which is worth doing for anything you would be stuck without if it were deleted tomorrow. The one thing to be deliberate about is what you are mirroring: a push mirror of a repository whose history contains a secret you meant to remove republishes that secret, immediately and automatically.
How do I give a contractor access to one repository without giving them the rest?
Use the layer that matches how long the access should last. For a person who will be reading, committing and opening pull requests, create a user account and add them as a collaborator on exactly the repositories they need, with an explicit permission level — read, write or admin — rather than putting them in an organisation team that quietly grants more. For an automated system that only needs to fetch — a deployment target, a build box, a mirror — use a read-only deploy key instead of a user account: it is scoped to one repository, it grants nothing else, and revoking it is deleting one line. For scripts that talk to the API, issue a scoped access token rather than handing over a password. Two habits make the whole thing hold: keep registration closed so the only accounts that exist are ones you created, and put a date in your calendar when you grant temporary access, because nothing in any forge will ever remind you that a contract ended.
Does the forge need a domain name, and does that leak my identity?
It needs a hostname for the web interface, because TLS certificates are issued for names and no browser should be trusting an IP address here. It does not need that name to be traceable to you. Three things to be deliberate about. Register the domain somewhere that does not demand the identity you were careful about elsewhere, and keep WHOIS privacy on — the registrar is a separate trust decision from the host, and it is the one people forget. Expect the hostname to become public the moment the certificate is issued, because every publicly trusted certificate is published in certificate-transparency logs that anyone can search; a wildcard on a domain you already use avoids adding a new searchable name. And if you would rather have no public name at all, the alternative is real: put the forge on a Tor onion service or reachable only over WireGuard, skip the public certificate entirely, and accept that the convenience of opening a browser anywhere goes with it.
Does NordBastion mind if I run a Git forge and CI runners on a VPS?
Not at all — a forge and its runners are an ordinary workload, and it is one of the more common things our customers build. The acceptable-use policy has nothing to say about hosting source code or building it. Two operational notes rather than policy ones. First, a CI runner that accepts workflows from strangers is a free compute service for whoever finds it: the standard abuse is cryptocurrency mining in a pull request, and it will be found within days of being open. Keep registration closed, and require approval before workflows run for anyone who is not already a collaborator. Second, if your builds pull large container images on every run, that is normal traffic and it is included — but it is worth caching them locally anyway, because it makes your pipeline faster and it is the polite thing to do to the upstream registries you depend on.
A forge of your own, on an account that is not your name. KYC-free, crypto-paid.
Sentinel — 2 vCPU and 4 GB for $3.90 a month, in Helsinki, Stockholm, Oslo or Reykjavík. Enough for Forgejo, PostgreSQL and the reverse proxy, with room for the repositories you have been meaning to move. Add a second one for the runner. No email at signup, no identity document, and no product-level index of anything you push.
Last reviewed · 2026-10-12 · Sources · Forgejo administrator documentation (installation, Actions, command line), the Forgejo comparison with Gitea, Codeberg e.V. governance pages, PostgreSQL and Caddy documentation, git-config(1) · Cadence · yearly
Anonymous VPS hosting in 2026 — the cluster.
This guide is one spoke of a larger series. The pillar walks the three privacy layers end to end — the sibling spokes below dive into the specifics.
Three independent layers — signup, payment, network — explained, legal context included, common mistakes flagged.
Bitwarden-compatible password vault under your own control.
Files, calendar, contacts, photos — owned, not rented.
Postfix, Dovecot, SPF/DKIM/DMARC — deliverability included.
A meta search engine that does not log you — because you own it.