Skip to content

How to Deploy Forgejo on a VPS

Updated Sep 2026

We earn commissions when you shop through the links below. Full disclosure →

Self-host Forgejo on your own VPS — the community-run hard fork of Gitea, stewarded by the Codeberg nonprofit — with HTTPS, SSH clones, and real backups.

Before you start
  • A VPS with 2 GB RAM (Forgejo itself runs in well under 512 MB — the headroom is for Postgres, a proxy, and large clones)
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain you can point at the server — HTTPS matters for Git credentials
  • Docker Engine + Compose installed (see the base guide below)
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

What Forgejo is

Forgejo is a self-hosted Git service: repositories, pull requests, issues, releases, organizations, a package registry, and Actions CI — the same feature set as Gitea, because Forgejo started as a hard fork of it. The difference isn't in day-to-day use, it's in who steers the project: Forgejo is stewarded by the Codeberg nonprofit rather than a company, which is the whole subject of the Gitea vs Forgejo comparison if that governance question matters to your decision. Technically the two remain close cousins — a single Go binary under the GPL-3.0-or-later licence, rated 2 / 5 to deploy, happy on 512 MB of RAM.

That closeness is also the trap. Forgejo has diverged from Gitea since the fork, and the divergence that actually bites during install is the environment-variable prefix the Docker image reads: FORGEJO__, not GITEA__. Copy a Gitea compose file without changing that and every GITEA__server__* setting is silently ignored.

Two things are worth deciding before you type anything. First, which database — Forgejo ships with SQLite, which is genuinely fine for a personal instance or a handful of collaborators, and speaks Postgres when a team starts opening pull requests concurrently. Second, how Git-over-SSH reaches the container, because that's the one part of a Dockerized forge that isn't obvious. Both get their own section below.

Server sizing — the repositories decide, not Forgejo

Forgejo's own footprint is small and stays small. The web service idles in the low hundreds of megabytes; the memory graph only moves when something does work — a large clone, a repository indexing pass, or a CI job.

Sizing by use:

  • 1 GB RAM / 1 vCPU — a personal forge with SQLite, dozens of repositories, one or two people. Comfortable.
  • 2 GB RAM / 2 vCPU — a small team on Postgres, with code search enabled and concurrent pushes. This is the sensible default.
  • 4 GB RAM+ — you're running Forgejo Actions runners on the same box. CI is the expensive tenant, not Forgejo; size for the builds, not the forge.

Disk is the axis people under-plan. Repositories, LFS objects, the package registry, and CI artifacts all land on the same volume, and none of them shrink on their own. Start at 40 GB and pick a provider where you can grow the disk later — most providers let you attach a volume or resize the disk without a rebuild.

Prepare the server

This guide assumes Docker Engine and the Compose plugin are installed, along with a non-root deploy user and a ufw firewall. If that's not done yet, work through Docker & Compose on Ubuntu first — it's the base layer for every app on this site.

With that in place, open the ports Forgejo actually needs. HTTP and HTTPS are for the reverse proxy; 222 is the host port that will carry Git-over-SSH into the container (the next section explains why it isn't 22):

sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw allow 222/tcp
sudo ufw enable
sudo ufw status verbose

Create the project directory as deploy:

mkdir ~/forgejo && cd ~/forgejo
Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

Install Forgejo

Forgejo's official Docker image is published at codeberg.org/forgejo/forgejo (a data.forgejo.org mirror exists if that registry is unreachable), tagged by major line — :16 tracks the current 16.0.x releases the way Gitea's :1 tag tracks its major line. It reads configuration from FORGEJO__section__KEY environment variables and writes everything else to /data. Start with the SQLite build — one container, no database to operate:

# docker-compose.yml
services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:16
    container_name: forgejo
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      # Public URL Forgejo prints in clone commands and emails.
      FORGEJO__server__ROOT_URL: "https://git.example.com/"
      FORGEJO__server__DOMAIN: "git.example.com"
      FORGEJO__server__SSH_DOMAIN: "git.example.com"
      # Port shown in ssh:// clone URLs …
      FORGEJO__server__SSH_PORT: "222"
      # … and the port the built-in SSH server listens on inside the container.
      FORGEJO__server__SSH_LISTEN_PORT: "22"
    volumes:
      - ./forgejo:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      # Web UI on loopback only — the reverse proxy is the sole public door.
      - "127.0.0.1:3000:3000"
      # Git-over-SSH: host 222 → container 22.
      - "222:22"

Bring it up:

docker compose up -d
docker compose logs -f

When to move to Postgres. SQLite holds up well for one person and small teams, but it serializes writes — with several people pushing, merging, and running CI at once you'll feel it. Switching is a database choice made before first run, so decide now rather than migrating later. Add a db service and point Forgejo at it:

# docker-compose.yml — Postgres variant
services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:16
    container_name: forgejo
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      FORGEJO__server__ROOT_URL: "https://git.example.com/"
      FORGEJO__server__DOMAIN: "git.example.com"
      FORGEJO__server__SSH_DOMAIN: "git.example.com"
      FORGEJO__server__SSH_PORT: "222"
      FORGEJO__server__SSH_LISTEN_PORT: "22"
      FORGEJO__database__DB_TYPE: "postgres"
      FORGEJO__database__HOST: "db:5432"
      FORGEJO__database__NAME: "forgejo"
      FORGEJO__database__USER: "forgejo"
      FORGEJO__database__PASSWD: "CHANGE_ME_LONG_RANDOM"
    volumes:
      - ./forgejo:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
      - "222:22"
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: forgejo
      POSTGRES_PASSWORD: CHANGE_ME_LONG_RANDOM
      POSTGRES_DB: forgejo
    volumes:
      - ./postgres:/var/lib/postgresql/data

Generate that password rather than inventing one:

openssl rand -base64 36 | tr -d '\n'

A rootless image variant also exists (:16-rootless), which drops the USER_UID/USER_GID environment vars in favor of a compose-level user: directive and moves the data path from /data to /var/lib/gitea — a leftover from the shared Gitea lineage. Rootful is the simpler default and what the rest of this guide assumes.

Git over SSH — pick one of two approaches

HTTPS clones work the moment the reverse proxy is up. SSH clones need a decision, because the container has its own SSH server and the host already has one listening on port 22.

Approach 1 — publish the container's SSH on a different host port. This is the compose file above: host 222 maps to container 22, and the two SSH_PORT / SSH_LISTEN_PORT settings make Forgejo advertise the right thing. Clone URLs come out as ssh://git@git.example.com:222/user/repo.git, users add their public key in the web UI, and nothing on the host changes. Take this one unless you have a specific reason not to — it is one line of compose and has no moving parts.

The only cost is the non-standard port, which some corporate firewalls block outbound. Users who don't want to type it can put it in ~/.ssh/config:

Host git.example.com
  Port 222
  User git

Approach 2 — host SSH passthrough. If clones must be on port 22, keep the host's sshd in front and have it hand Git commands to the container. That means a git user on the host, an AuthorizedKeysCommand in sshd_config that calls the container's forgejo keys subcommand to look keys up, and a shell wrapper that forwards SSH_ORIGINAL_COMMAND in via docker exec. It works, and it's how you get plain git@git.example.com:user/repo.git URLs — but Forgejo's own docs don't walk through the setup step by step the way Gitea's do (the mechanism is identical — the same AuthorizedKeysCommand/docker exec wrapper, just forgejo keys in place of gitea keys — but that's a different app's docs for a fork that's since diverged, so treat it as a reference for the mechanism rather than a copy-paste guide). It also touches the host's SSH daemon, which is the one service you really don't want to misconfigure on a remote box — keep a second SSH session open while you edit sshd_config.

HTTPS + domain

Forgejo handles credentials, tokens, and private source — serve it over TLS only. Create an A record for git.example.com pointing at the server's public IP and wait for it to resolve before requesting a certificate.

Then put a reverse proxy in front of 127.0.0.1:3000. The simplest path is Automatic HTTPS with Caddy; the Caddyfile entry is one block:

git.example.com {
    reverse_proxy 127.0.0.1:3000
}

If you run Caddy as a container rather than on the host, 127.0.0.1 is the proxy's loopback, not the host's. Put Caddy and Forgejo in the same compose file and proxy to the service name instead — reverse_proxy forgejo:3000 — dropping the 127.0.0.1:3000 publish.

One gotcha inherited from the same underlying HTTP stack Gitea uses: large pushes need a generous body limit. Caddy streams request bodies without a size cap by default, so it's fine as-is; on nginx you must raise client_max_body_size (say 512m) or pushes of a big repository fail with an opaque HTTP 413.

First login and hardening

Load https://git.example.com. On first run Forgejo shows an installation page — confirm the database settings, set the Server Domain and Base URL to your real hostname, and finish. The settings are written to ./forgejo/gitea/conf/app.ini, which becomes the file of record from then on.

The first registered account becomes the administrator. Register yourself immediately, before anything else. Then close the door:

  1. Disable open registration. In app.ini, under [service], set DISABLE_REGISTRATION = true (or add FORGEJO__service__DISABLE_REGISTRATION: "true" to the compose environment and recreate). New users are then created by an admin, or invited. An internet-facing forge with open signups collects spam accounts within days.
  2. Turn on two-factor auth for your own account under Settings → Security, and require it for admins.
  3. Decide whether anonymous browsing is allowed. REQUIRE_SIGNIN_VIEW = true under [service] makes the whole instance private — the right setting for a company forge, the wrong one if you publish open source.
  4. Restrict who can create organizations and repositories if more than a couple of people will have accounts.

Apply config changes with a restart:

docker compose restart forgejo

Backups

A Forgejo backup is three things, and missing any one of them makes the restore partial:

  • The repositories — bare Git repos under ./forgejo/git/repositories.
  • The database — ./forgejo/gitea/forgejo.db for SQLite, or a pg_dump for Postgres. This holds users, issues, pull requests, and permissions. Repos without it are just code with no history of the conversation around them.
  • The custom directory — ./forgejo/gitea, which carries conf/app.ini, the attachments, avatars, LFS objects, and the SSH host keys.

Forgejo's own dump command captures all of that in one consistent archive, which is far safer than copying a live SQLite file:

docker compose exec -u git -w /tmp forgejo \
  forgejo dump -c /data/gitea/conf/app.ini -f /tmp/forgejo-backup.zip
docker compose cp forgejo:/tmp/forgejo-backup.zip ./forgejo-$(date +%F).zip
docker compose exec -u git forgejo rm /tmp/forgejo-backup.zip

The -u git matters: docker compose exec without it runs as the container's default user, which is root — and Forgejo explicitly refuses to run as root, so the dump fails with Forgejo is not supposed to be run as root unless you drop to the git user (UID 1000) first.

Ship that file somewhere the VPS dying doesn't take with it — object storage, another machine, your laptop. Run it on a schedule, and restore it once into a throwaway instance so you find out now, not during an outage, that the dump is complete. The Forgejo project treats a full backup as a hard requirement before any upgrade, not just good practice — see below. If you're on Postgres, add a pg_dump alongside it:

docker compose exec db pg_dump -U forgejo forgejo | gzip > forgejo-db-$(date +%F).sql.gz

Upgrades

Forgejo's :16 tag tracks the current major line, so an upgrade is a deliberate pull:

cd ~/forgejo
docker compose pull
docker compose up -d

Forgejo's own docs call a full backup "a requirement when upgrading to a new stable release" — take a dump first (previous section), since database migrations run automatically at startup and are one-way. Skim the release notes before a version jump; Forgejo follows semantic versioning from 7.0 onward, so breaking changes are reserved for major-version bumps. If you'd rather control the exact version, pin a full version tag instead of the major line and bump it on your own schedule.

Troubleshooting

Clone URLs show the wrong host or port. Forgejo generates them from ROOT_URL, SSH_DOMAIN, and SSH_PORT. If you set up behind a proxy without setting ROOT_URL, it guesses from the request and gets it wrong. Fix the values in the compose environment: block and recreate the container — the FORGEJO__ variables are re-applied to app.ini on every container start by environment-to-ini, so a hand-edit of app.ini alone is overwritten.

Settings you set don't seem to take effect. The single most common cause on a fresh Forgejo install is leaving a GITEA__ prefix in place from a copied Gitea compose file. Forgejo's environment-to-ini tool only recognizes FORGEJO__ — the old prefix is silently ignored rather than rejected.

git push over SSH says "Permission denied (publickey)". Three usual causes: the key was added to the wrong account, you're hitting the host's sshd on port 22 instead of the container on 222, or the container's SSH server never started. Test which daemon answers with ssh -p 222 -T git@git.example.com — Forgejo replies with a greeting naming your username. A plain Permission denied from port 22 means you reached the host.

Pushes fail over HTTPS with a 413 or hang on large repositories. The reverse proxy is capping the request body. Raise the limit (nginx: client_max_body_size 512m) or push over SSH, which the proxy never sees.

Web UI is up but avatars, attachments, or LFS are missing after a restore. The custom directory didn't come back. Repositories and the database alone are not a complete restore — ./forgejo/gitea has to be there too.

Permission errors on /data after moving the volume. The rootful container runs as UID/GID 1000 by default. If the host directory is owned by someone else, chown -R 1000:1000 ./forgejo (or set USER_UID/USER_GID to match) and recreate.

Verification + next steps

You're done when you can: load https://git.example.com with a valid certificate, sign in as your admin account with 2FA on, confirm open registration is closed in a private window, create a repository, push to it over both HTTPS and SSH, open a pull request, and produce a forgejo dump you have restored at least once.

From there, the natural next steps are enabling Forgejo Actions with a runner for CI, turning on the package registry to host your own container images, and wiring notifications into your chat. If you're weighing this against the Gitea original, Gitea vs Forgejo covers the governance split; if you're weighing it against a heavier, code-intelligence-focused forge, Forgejo vs OneDev covers that trade-off. For the ranked host picks under a forge like this, see Best VPS for Gitea — the sizing math (small, disk-bound, RAM-light) carries over almost exactly.

Next steps

How to self-host Forgejo →More self-hosted code hosting tools →Best VPS for Gitea →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.