Self-hosted Git server with Forgejo (and Gitea) on a VPS
A self-hosted Git server with Forgejo or Gitea on a VPS: Docker and PostgreSQL install, Git over SSH, CI runner, GitHub mirrors, backups and common pitfalls.
Current as of September 2026: Forgejo 15.0.9 LTS and 16.0.5, forgejo-runner 13.2, Gitea 28.0. Docker and nginx basics live in separate articles; this one focuses on what breaks in production.
What you'll need
- A ForestsNet Cloud VPS running Ubuntu 24.04 or Debian 12/13 with Docker Compose — see Docker and Docker Compose.
- Your own subdomain such as
git.example.comwith an A record pointing at the server — see Pointing a domain at your server. - Caddy or nginx for HTTPS — see the articles on Caddy or nginx with Let's Encrypt.
- Key-only SSH login — see How to harden SSH on your VPS — and backup storage outside the server.
Step 1. Decide whether you need your own server, and which one
Forgejo or Gitea
Forgejo is a fork of Gitea. In October 2022 the Gitea domain and trademark were transferred to a newly formed company, Gitea Ltd. Some contributors asked for them to go to a non-profit instead, and when that did not happen, they launched Forgejo in December under Codeberg e.V. It has been a hard fork since 2024, and new code is GPLv3+ since version 9.0. Gitea is still MIT-licensed; CommitGo sells the paid Enterprise and Cloud editions.
- Releases. Forgejo ships every quarter: a stable release is supported for three months, an LTS for 15. The current LTS is 15.0 (until 15 July 2027), while 16.0 is supported until 29 October. Gitea has no LTS branch, and Gitea 28.0, released at the end of September, dropped the "1." prefix.
- Registration. Gitea 28.0 disables self-registration by default. In Forgejo the installer disables it, but the config default leaves it open.
- Migration. Gitea up to 1.22 upgrades to Forgejo the supported way, via version 10. From newer Gitea you are left with manual database work or moving repositories one by one.
- CI. Forgejo has forgejo-runner, which can also use LXC; Gitea has Gitea Runner, formerly act_runner.
Pick Forgejo for non-profit governance and an LTS line, Gitea Enterprise for vendor support and SAML. The setup below fits both: for Gitea, use the docker.gitea.com/gitea image, the GITEA__ variable prefix and Gitea Runner.
When a self-hosted server makes sense
It makes sense when your code has to live on infrastructure you control: private projects, client requirements on where data is stored, or the risk of a SaaS provider restricting access. In 2025, Organic Maps moved 54,000 commits to its own Forgejo after GitHub restricted one of its developers. If nobody on the team will apply updates and test backups, or an open-source project needs GitHub's audience, skip it; for open-source projects without their own infrastructure, Codeberg is the easy option.
Step 2. Choose how Git over SSH reaches the container
The standard Forgejo image runs OpenSSH on port 22 inside the container, while port 22 on the host already belongs to the system sshd. There are three options:
- A separate port. Container port 22 is published on host port 2222, and clone URLs look like
ssh://[email protected]:2222/team/app.git. The Forgejo docs called this the most secure option. Developers can hide the port with aHostblock in~/.ssh/config. - Port 22 for Git. Move the system sshd to another port and give 22 to the container. URLs get shorter, but the admin port is easy to mix up.
- Passthrough via the host sshd with
AuthorizedKeysCommand. Gitea and Forgejo both removed these instructions from their docs in 2026: quoting ofSSH_ORIGINAL_COMMANDbreaks easily, and thedocker execvariant gives the hostgituser access to Docker, which amounts to root.
If you need port-less URLs like [email protected]:team/app.git, install Forgejo as a binary under systemd: it works through the system sshd. The rest of this guide uses Docker and port 2222.
Step 3. Run Forgejo and PostgreSQL with Docker Compose
Generate a database password. A hex string contains no # or ;, which would need escaping in app.ini:
sudo mkdir -p /opt/forgejo && cd /opt/forgejo
echo "DB_PASSWORD=$(openssl rand -hex 24)" | sudo tee .env
sudo chmod 600 .env/opt/forgejo/compose.yaml:
services:
forgejo:
image: codeberg.org/forgejo/forgejo:15
container_name: forgejo
restart: always
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=${DB_PASSWORD}
- FORGEJO__server__DOMAIN=git.example.com
- FORGEJO__server__SSH_DOMAIN=git.example.com
- FORGEJO__server__ROOT_URL=https://git.example.com/
- FORGEJO__server__SSH_PORT=2222
- FORGEJO__server__LFS_START_SERVER=true
- FORGEJO__service__DISABLE_REGISTRATION=true
- FORGEJO__security__REVERSE_PROXY_TRUSTED_PROXIES=127.0.0.0/8,::1/128,172.16.0.0/12
- FORGEJO__log__MODE=console,file
volumes:
- ./data:/data
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000"
- "2222:22"
depends_on:
- db
db:
image: postgres:18
container_name: forgejo-db
restart: always
environment:
- POSTGRES_USER=forgejo
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=forgejo
volumes:
- ./postgres:/var/lib/postgresqlsudo docker compose up -d
sudo docker compose logs -f forgejo- The
15tag tracks the latest 15.0.x LTS and never jumps to the next major version on its own. If codeberg.org is unreachable, thedata.forgejo.orgmirror has the same images. 127.0.0.1:3000. Ports published by Docker bypass ufw rules, so without the address the web port would be open to the internet.FORGEJO__section__KEYvariables are written into/data/gitea/conf/app.inion every container start, so editing the same key in the file gets reverted.SSH_PORT=2222only changes clone URLs. OpenSSH in the container still listens on 22, and the built-in server'sSSH_SERVER_*settings do not apply to it.REVERSE_PROXY_TRUSTED_PROXIES. The proxy reaches the container from the Docker gateway address. The 16.0 image stopped trusting every address, so without this line your logs show the gateway instead of clients. The 15.0 image writes*into a fresh config, trustingX-Forwarded-Forfrom anyone.docker network inspect forgejo_defaultshows the exact subnet.postgres:18is mounted at/var/lib/postgresql. With/var/lib/postgresql/data, as in the documentation'spostgres:14example, it refuses to start, and PostgreSQL 14 reaches end of life on 12 November 2026.
PostgreSQL or SQLite
Forgejo supports SQLite, PostgreSQL 14+, MySQL 8.4+ and MariaDB 10.6+. The docs recommend SQLite for light use, but it handles concurrent writes poorly. After the 14.0 upgrade, the database is locked issue collected more than 40 comments, with runners and the container registry triggering the locks. In another report, cleaning up 20 GB of images on SQLite pinned a core at 100% for about 50 minutes. With Actions and the registry, start on PostgreSQL: the docs call switching databases later non-trivial.
Step 4. Run the installer through an SSH tunnel
The web port only listens on 127.0.0.1, so only you can see the installer. Don't publish the domain until setup is finished: anyone can complete an unfinished installer, and new certificates appear in public Certificate Transparency logs that scanners watch. Open a tunnel and browse to http://localhost:3000:
ssh -L 3000:127.0.0.1:3000 user@SERVER_IPThe fields from compose are prefilled. The option to disable self-registration is checked by default, so create the administrator account right there. The installer generates SECRET_KEY and INTERNAL_TOKEN itself. To create an admin from the shell, use the command below; without -u git it fails with Forgejo is not supposed to be run as root:
sudo docker exec -u git forgejo forgejo admin user create --admin \
--username admin_name --email [email protected] --random-passwordStep 5. Add your domain and TLS
Caddy gets the certificate itself and does not limit request size:
git.example.com {
reverse_proxy 127.0.0.1:3000
}For nginx, take the config from the Forgejo docs and issue a certificate with sudo certbot --nginx -d git.example.com:
server {
listen 80;
listen [::]:80;
server_name git.example.com;
merge_slashes off;
client_max_body_size 2G;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Connection $http_connection;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}By default nginx accepts request bodies up to 1 MB and the Forgejo docs use 512 MB, but an image layer or an LFS file can be larger, and the client then gets 413. merge_slashes off is needed for paths with encoded slashes. Serving Forgejo from the root of a subdomain is simplest: the container registry always uses the path /v2, so a sub-path needs extra proxy routes.
Step 6. Do the initial setup
Registration. Keep it closed and create accounts from the admin panel. If you do need open sign-ups, enable ENABLE_CAPTCHA, email confirmation or manual approval, and EMAIL_DOMAIN_ALLOWLIST for a company domain. Spam is not the only risk. CVE-2026-60004 in Gitea's diffpatch API gave code execution to anyone with write access to a repository, and with open registration that meant any visitor. In August 2026 it was exploited at scale to install cryptominers. One incident write-up on Habr shows a bot going from the sign-up form to running code on the server in 11 seconds. Between July and September 2026 Forgejo fixed three code-execution bugs, in 15.0.6/16.0.2, 15.0.8/16.0.4 and 15.0.9/16.0.5, and each needed nothing more than the right to create your own repository.
Private viewing. For a team server, set FORGEJO__service__REQUIRE_SIGNIN_VIEW=true so guests only see the login page. It also shuts out aggressive crawlers, which ignore robots.txt, come from tens of thousands of residential IPs and walk every commit. On 25 November 2025, code.forgejo.org was getting requests from about 50,000 unique addresses an hour. A public instance needs a proof-of-work gate such as Anubis.
Email is needed for password resets and notifications. Add your external SMTP service to environment:
- FORGEJO__mailer__ENABLED=true
- FORGEJO__mailer__PROTOCOL=smtps
- FORGEJO__mailer__SMTP_ADDR=smtp.example.com
- FORGEJO__mailer__SMTP_PORT=465
- [email protected]
- FORGEJO__mailer__PASSWD=${SMTP_PASSWORD}
- [email protected]
- FORGEJO__service__ENABLE_NOTIFY_MAIL=trueSMTP authentication only works over TLS: smtps on 465 or smtp+starttls on 587. You can send a test email from the admin panel. Turn on two-factor authentication for administrators.
Step 7. Set up Forgejo Actions and a runner
Actions jobs are run by a separate program, forgejo-runner. It polls Forgejo over HTTPS and needs no inbound ports. Keep it on another VPS, because CI runs other people's code. A host label gives no isolation at all, so a job can reach services on 127.0.0.1 behind the proxy's back, and with the Docker socket (docker_host: automount) it can docker exec into the Forgejo container and make itself an admin.
On the runner server, prepare the directory and config for user 1001, which the compose file below runs the runner as:
sudo mkdir -p /opt/runner/data/.cache && cd /opt/runner
sudo chown -R 1001:1001 data && sudo chmod 775 data/.cache && sudo chmod g+s data/.cache
sudo docker run --rm data.forgejo.org/forgejo/runner:13 forgejo-runner generate-config | sudo tee data/runner-config.yml >/dev/null
sudo chown 1001:1001 data/runner-config.yml/opt/runner/compose.yaml, following the docs: jobs run in a nested Docker and cannot see the host's Docker.
services:
docker-in-docker:
image: docker:dind
privileged: true
command: ["dockerd", "-H", "tcp://0.0.0.0:2375", "--tls=false"]
restart: unless-stopped
runner:
image: data.forgejo.org/forgejo/runner:13
depends_on:
- docker-in-docker
environment:
DOCKER_HOST: tcp://docker-in-docker:2375
user: "1001:1001"
volumes:
- ./data:/data
restart: unless-stopped
command: forgejo-runner daemon --config runner-config.ymlCreate the runner under Actions → Runners at the instance, organization or repository level; the narrower the scope, the lower the risk. The UI shows a UUID and a token, and forgejo-runner register is deprecated. In runner-config.yml, replace labels: [] in the runner section and the empty server section at the end of the file, then run sudo docker compose up -d:
labels:
- docker:docker://node:lts-trixie
- ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04
server:
connections:
forgejo:
url: https://git.example.com/
uuid: UUID_FROM_THE_UI
token: TOKEN_FROM_THE_UIA label maps runs-on to an image. Workflows live in .forgejo/workflows/, and if that directory is missing Forgejo reads .github/workflows/. A minimal example:
on: [push, pull_request]
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v6
- run: npm ci && npm testThe project aims for familiarity rather than compatibility with GitHub Actions:
- Environment. A typical image is Debian with Node.js, while GitHub uses a large Ubuntu image; the catthehacker images come closer. The runner never updates an image it has pulled, so pin versions or enable
force_pull. - Actions.
actions/checkout@v6is fetched from data.forgejo.org; reference other actions by full URL, such ashttps://github.com/owner/action@v1. Instead ofactions/upload-artifact@v4, usehttps://data.forgejo.org/forgejo/upload-artifact@v4. - Syntax. Job-level
permissionsandcontinue-on-errorare ignored. Runner 13 (August 2026) removed::set-output,::set-envand::add-path; write to$GITHUB_OUTPUT,$GITHUB_ENVand$GITHUB_PATHinstead. Expression errors now fail the job, and Docker 25 or newer is required. - Permissions. Pull requests from authors with read-only access wait for approval. Mirrors and forks have Actions disabled by default.
The GitHub Actions runner from our other article works the same way — outbound connection, labels, job isolation — but it only serves repositories hosted on GitHub.
Step 8. Set up mirrors, packages and LFS
Mirroring from GitHub: New Migration → GitHub, the URL, a token (required for private repositories, and it lifts the API rate limit) and the mirror checkbox. A mirror pulls branches, tags, the wiki and optionally LFS, but not issues or pull requests; only a one-off migration without the mirror checkbox brings those over. It syncs every 8 hours by default and no more often than every 10 minutes, and an existing repository cannot be turned into a mirror. Since 16.0 mirrors no longer follow HTTP redirects, so a mirror of a repository renamed on GitHub stops updating.
Mirroring to GitHub is set up under Settings → Repository → Mirror Settings with a GitHub fine-grained token that has Contents permission, plus Workflows if the repository has .github/workflows. A push mirror force-pushes and overwrites changes on GitHub, and over SSH it does not transfer LFS.
Packages. Over 20 formats: npm, PyPI, Maven, Debian, Helm, generic and more. For Docker, run docker login git.example.com with an access token (a password won't work with 2FA) and name images like git.example.com/team/app:1.0. Set cleanup rules, or the registry will only grow.
LFS is enabled by LFS_START_SERVER=true, and files are stored in data/git/lfs. Objects no longer referenced by any commit are not deleted while the gc_lfs task is disabled, which is the default.
Disk space also goes to Actions artifacts (kept 90 days), job logs (365 days), source archives generated on download, and the code search index if you enable it, which the docs put at about six times the size of the repositories. sudo du -sh /opt/forgejo/data/*/* shows where the space went; see also Running out of disk space.
Step 9. Set up backups and upgrades
Backups are your job here. The database and files must match in time, so Forgejo is stopped while copying, and the database is dumped with pg_dump: the docs consider the SQL dump inside forgejo dump unreliable. The /opt/forgejo/backup.sh script:
#!/bin/bash
set -euo pipefail
source /etc/restic/env
cd /opt/forgejo
mkdir -p backup
docker compose stop forgejo
trap 'docker compose start forgejo' EXIT
docker compose exec -T db pg_dump -U forgejo -Fc forgejo > backup/forgejo.dump
restic backup data backup compose.yaml .envWithout -T, Docker allocates a terminal and corrupts the binary dump. For /etc/restic/env, restic and scheduling, see Incremental backups with restic and Scheduled backups with cron; for dumps, see Database backups. forgejo dump is handy for moving servers as one archive, but it needs free space at least equal to your data.
Test a restore on a separate VPS every quarter:
- Pin the exact version the backup was taken on in compose, such as
15.0.9: a newer one would start migrations mid-restore. - Restore
/opt/forgejofrom restic, start the database (docker compose up -d db) and load the dump:docker compose exec -T db pg_restore -U forgejo -d forgejo < backup/forgejo.dump. - Start Forgejo, run
docker exec -u git forgejo forgejo doctor check --alland test cloning over SSH and HTTPS.
On 15.0, run forgejo admin regenerate hooks after the install path changes; 16.0 stores hooks centrally and no longer has that command.
Upgrades. Patch releases go in after a backup: docker compose pull && docker compose up -d. Before a major version, read the breaking changes in the release notes, back up, run docker exec -u git forgejo forgejo manager flush-queues and change the tag. Skipping versions is allowed; if a migration fails, step through the last patch of each series to find the culprit. Security releases are announced in advance in the security-announcements repository on Codeberg. A major PostgreSQL upgrade is not a tag change either: it takes a dump and a restore.
Step 10. Lock it down
- Secure the host with How to harden SSH on your VPS, The ufw firewall and the VPS security checklist. Only 80, 443, 2222 and your admin SSH port need to be open.
- As of late September 2026, run at least 15.0.9 or 16.0.5.
- Set up Fail2ban for failed web logins. Forgejo writes
Failed authentication attempt for NAME from IPtodata/gitea/log/gitea.logonce file logging is on and trusted proxies are set. The container's SSH accepts keys only. - Without mirrors and external webhooks, the container does not need internet access: restrict it with
DOCKER-USERrules. In the incident from step 6, the miner was downloaded exactly that way.
The filter /etc/fail2ban/filter.d/forgejo.local and the jail /etc/fail2ban/jail.d/forgejo.local:
[Definition]
failregex = .*(Failed authentication attempt|invalid credentials|Attempted access of unknown user).* from <HOST>
ignoreregex =[forgejo]
enabled = true
filter = forgejo
logpath = /opt/forgejo/data/gitea/log/gitea.log
port = http,https
maxretry = 10
findtime = 3600
bantime = 900Bans on 80 and 443 work because web traffic hits the proxy on the host. If the proxy runs in Docker, ban in the DOCKER-USER chain instead.
How many resources you need
Forgejo itself uses 100–200 MB after startup; container metrics such as docker stats can show more, since they include file cache. The Gitea docs consider 2 cores and 1 GB of RAM enough for a small team. The load comes from builds, code indexing, archives and crawlers: one user's 2 GB server started swapping when Forgejo grew to 1.1 GB under a flood of requests.
- A team of up to 5. Forgejo and PostgreSQL: 1–2 vCPU and 2 GB, with 20–40 GB of NVMe or more (repositories × 3 plus LFS and the registry). A separate runner: 2 vCPU and 4 GB for typical Node.js, Python and Go builds. Everything on one machine: at least 4 vCPU and 8 GB, and only for trusted code.
- A team of around 50. Forgejo and PostgreSQL: 4 vCPU and 8 GB, with 1–2 GB of that for
shared_buffers. Move the cache to Redis or Valkey ([cache] ADAPTER = redis) and code search to an external engine instead of the built-in bleve. For CI, two or more runners on their own machines.
On ForestsNet Cloud only outbound traffic counts toward your plan: clones, fetches and image pulls from other machines. Pushes, mirroring from GitHub and pulling base images are inbound and don't count.
Troubleshooting
Permission denied (publickey)although the key is added. The client hits the host sshd on 22 instead of 2222. Check withssh -T -p 2222 [email protected]: Forgejo repliesHi there, NAME!.REMOTE HOST IDENTIFICATION HAS CHANGEDafter a move. Thedata/sshdirectory with the host keys was not restored.- Clone URLs show
localhostor port 3000.DOMAIN,SSH_DOMAINandROOT_URLare not set, and the image template writeslocalhost. - A job waits for a runner forever. No runner has the
runs-onlabel, or Actions is disabled in the repository, as it is for mirrors and forks. Cannot connect to the Docker daemoninside a job. You need Docker-in-Docker ordocker_host, with the risks from step 7.- A mirror stopped after moving to 16.0. The GitHub repository was renamed, and Forgejo no longer follows redirects. Also check whether the token expired.
- Every address in the logs is 172.x.x.x.
REVERSE_PROXY_TRUSTED_PROXIESis not set. 413 Request Entity Too Largeon push. Raiseclient_max_body_sizein nginx.postgres:18exits right away. The volume must be mounted at/var/lib/postgresql.- CPU at 100% and
repo-archivekeeps growing. Crawlers: enableREQUIRE_SIGNIN_VIEWor put a gate in front of Forgejo. If the code indexer spins for hours, delete its directory underdata/gitea/indexers/and restart Forgejo.
Sources
- Forgejo docs: Docker install, database preparation, recommended settings, reverse proxy, config cheat sheet, command line, upgrade guide, release schedule
- Forgejo Actions: admin guide, runner in Docker, registration, labels, security, Docker in jobs, differences from GitHub Actions, artifacts, runner 13.0
- Forgejo: mirrors, container registry
- Forgejo releases and security: 16.0.5, 15.0.9, PR 12782, security-announcements
- History: Forgejo announcement, hard fork, GPL, Gitea compatibility, open letter, LWN
- Forgejo discussions and issues: crawlers, crawlers, November 2025, 13.0.0 secrets corruption, 13 → 14 migration failure, database is locked, registry cleanup, memory, bleve indexer
- Gitea: requirements, Docker, Fail2ban, backup, Gitea 28.0.0, on Gitea Ltd, Enterprise, CVE-2026-60004 advisory, BleepingComputer on its exploitation
- Write-ups (in Russian, Habr): cryptominer via Gitea RCE, Gitea with a tested restore, corporate Forgejo behind a VPN, Organic Maps moves to Forgejo
- Other: Docker and ufw, postgres 18 data directory change, PostgreSQL versioning policy