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

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 a Host block 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 of SSH_ORIGINAL_COMMAND breaks easily, and the docker exec variant gives the host git user 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/postgresql
sudo docker compose up -d
sudo docker compose logs -f forgejo
  • The 15 tag tracks the latest 15.0.x LTS and never jumps to the next major version on its own. If codeberg.org is unreachable, the data.forgejo.org mirror 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__KEY variables are written into /data/gitea/conf/app.ini on every container start, so editing the same key in the file gets reverted.
  • SSH_PORT=2222 only changes clone URLs. OpenSSH in the container still listens on 22, and the built-in server's SSH_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, trusting X-Forwarded-For from anyone. docker network inspect forgejo_default shows the exact subnet.
  • postgres:18 is mounted at /var/lib/postgresql. With /var/lib/postgresql/data, as in the documentation's postgres:14 example, 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_IP

The 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-password
Important: if you skip the installer with FORGEJO__security__INSTALL_LOCK=true, also set SECRET_KEY, generated with forgejo generate secret SECRET_KEY. With an empty key, Forgejo silently falls back to a well-known default value.

Step 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=true

SMTP 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.yml

Create 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_UI

A 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 test

The 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@v6 is fetched from data.forgejo.org; reference other actions by full URL, such as https://github.com/owner/action@v1. Instead of actions/upload-artifact@v4, use https://data.forgejo.org/forgejo/upload-artifact@v4.
  • Syntax. Job-level permissions and continue-on-error are ignored. Runner 13 (August 2026) removed ::set-output, ::set-env and ::add-path; write to $GITHUB_OUTPUT, $GITHUB_ENV and $GITHUB_PATH instead. 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 .env

Without -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:

  1. Pin the exact version the backup was taken on in compose, such as 15.0.9: a newer one would start migrations mid-restore.
  2. Restore /opt/forgejo from 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.
  3. Start Forgejo, run docker exec -u git forgejo forgejo doctor check --all and 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.

Important: don't install x.0.0 on release day, and never upgrade without a backup. The Forgejo 13.0.0 migration in October 2025 corrupted Actions secrets on PostgreSQL instances with more than 50 of them. In March 2026 a 13 → 14 upgrade on SQLite failed mid-migration, and there is no going back: Forgejo will not start on a database from a newer version.

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 IP to data/gitea/log/gitea.log once 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-USER rules. 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 = 900

Bans 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 with ssh -T -p 2222 [email protected]: Forgejo replies Hi there, NAME!.
  • REMOTE HOST IDENTIFICATION HAS CHANGED after a move. The data/ssh directory with the host keys was not restored.
  • Clone URLs show localhost or port 3000. DOMAIN, SSH_DOMAIN and ROOT_URL are not set, and the image template writes localhost.
  • A job waits for a runner forever. No runner has the runs-on label, or Actions is disabled in the repository, as it is for mirrors and forks.
  • Cannot connect to the Docker daemon inside a job. You need Docker-in-Docker or docker_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_PROXIES is not set.
  • 413 Request Entity Too Large on push. Raise client_max_body_size in nginx.
  • postgres:18 exits right away. The volume must be mounted at /var/lib/postgresql.
  • CPU at 100% and repo-archive keeps growing. Crawlers: enable REQUIRE_SIGNIN_VIEW or put a gate in front of Forgejo. If the code indexer spins for hours, delete its directory under data/gitea/indexers/ and restart Forgejo.

Sources