All articles
TutorialsFeb 23, 2026 · 21 min read

Self-Host Forgejo on a VPS for a Private Git Server

Self-Host Forgejo on a VPS for a Private Git Server

GitHub is great until it isn't. Maybe you're tired of paying per seat for private repos. Maybe a client requires that source code stays on your infrastructure. Or maybe you just want a quiet corner of the internet where your git push doesn't get scanned by half a dozen integrations.

Forgejo is a community-run, MIT-licensed fork of Gitea. It looks and feels like a slimmed-down GitHub: repositories, pull requests, issues, organizations, even a CI runner. It's small enough to run comfortably on a 1 GB VPS and serious enough to host a team's daily work.

This guide walks through a production-ready setup: Forgejo and Postgres in Docker Compose, Caddy in front for automatic HTTPS, SSH access on a dedicated port, the initial admin account, and built-in backups using forgejo dump.

Forgejo's first-time setup screen lets you flag the very first registered user as an admin. Hit the install page before anyone else does, then turn open registrations off. Until you do, anyone who finds your domain can sign up.

TL;DR

  • Install Docker and Docker Compose on a fresh VPS
  • Point a subdomain like git.example.com at your server
  • Run Forgejo plus Postgres behind a single docker-compose.yml
  • Caddy handles HTTPS for the web UI on 443
  • Expose Git over SSH on port 2222 to avoid colliding with the host's sshd
  • Create the first admin account, disable open signups, build an org
  • Schedule a daily forgejo dump plus a pg_dump to a separate volume

Total time: about 15 minutes.

What You Need

  • A VPS with at least 1 GB RAM (2 GB recommended) running Ubuntu 22.04 or 24.04
  • A domain name with DNS access
  • Ports 80, 443, and 2222 open to the internet
  • Root or sudo access on the server

Forgejo is light. A small repo with a few users idles below 200 MB of RAM. Postgres adds another 100 MB or so. The bigger constraint is disk: plan for the full size of every repo you'll mirror plus a generous buffer for backups.

Step 1: Point a Subdomain at Your VPS

In your DNS provider, add an A record:

git.example.com -> YOUR_VPS_IPV4

Add an AAAA record for IPv6 if you have it. Then verify:

dig +short git.example.com

The output should match your VPS IP. DNS needs to resolve before Caddy can fetch a Let's Encrypt certificate.

Step 2: Install Docker and Docker Compose

On a fresh Ubuntu host:

sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Confirm both binaries are present:

docker --version docker compose version

Step 3: Open the Firewall

If you use UFW:

sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw allow 2222/tcp sudo ufw enable

Caddy needs 80 for the ACME HTTP challenge and 443 for HTTPS. Port 2222 is where Forgejo will accept SSH for git push and git pull. We deliberately avoid port 22 so it doesn't fight with the host's own sshd.

Don't try to bind Forgejo's container directly to `:22` on the host. The host's OpenSSH is already there, and you really don't want to share it with a containerized service. Use `2222` (or any other port you like) and configure clients accordingly.

Step 4: Create the Project Directory

sudo mkdir -p /opt/forgejo cd /opt/forgejo sudo mkdir -p forgejo-data postgres-data caddy-data caddy-config

Everything persistent lives under /opt/forgejo. Back up this directory and you can rebuild the stack on any other VPS.

Step 5: Generate a Postgres Password

openssl rand -base64 32

Save it somewhere safe. Forgejo generates its own internal tokens on first start, so you only need this one secret right now.

Step 6: Write the Environment File

Create /opt/forgejo/.env:

# Domain FORGEJO_DOMAIN=git.example.com FORGEJO_ROOT_URL=https://git.example.com/ FORGEJO_SSH_DOMAIN=git.example.com FORGEJO_SSH_PORT=2222 # Database POSTGRES_USER=forgejo POSTGRES_PASSWORD=REPLACE_WITH_POSTGRES_PASSWORD POSTGRES_DB=forgejo # Run as a normal user inside the container USER_UID=1000 USER_GID=1000

Lock it down so only root can read it:

sudo chmod 600 /opt/forgejo/.env

Step 7: Write the Compose File

Create /opt/forgejo/docker-compose.yml:

services: postgres: image: postgres:16 container_name: forgejo-postgres restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - ./postgres-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 networks: - forgejo-net forgejo: image: codeberg.org/forgejo/forgejo:9 container_name: forgejo restart: unless-stopped depends_on: postgres: condition: service_healthy environment: USER_UID: ${USER_UID} USER_GID: ${USER_GID} FORGEJO__database__DB_TYPE: postgres FORGEJO__database__HOST: postgres:5432 FORGEJO__database__NAME: ${POSTGRES_DB} FORGEJO__database__USER: ${POSTGRES_USER} FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD} FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN} FORGEJO__server__ROOT_URL: ${FORGEJO_ROOT_URL} FORGEJO__server__SSH_DOMAIN: ${FORGEJO_SSH_DOMAIN} FORGEJO__server__SSH_PORT: ${FORGEJO_SSH_PORT} FORGEJO__server__SSH_LISTEN_PORT: "2222" FORGEJO__server__START_SSH_SERVER: "true" FORGEJO__service__DISABLE_REGISTRATION: "false" FORGEJO__service__REQUIRE_SIGNIN_VIEW: "true" volumes: - ./forgejo-data:/var/lib/gitea - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ports: - "2222:2222" networks: - forgejo-net caddy: image: caddy:2 container_name: forgejo-caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy-data:/data - ./caddy-config:/config networks: - forgejo-net networks: forgejo-net:

A few notes on what's going on:

  • The web port (3000 inside the container) is not published to the host. Caddy reaches it over the Docker network.
  • Port 2222 is published to the host so Git clients can connect with ssh://[email protected]:2222/....
  • FORGEJO__service__DISABLE_REGISTRATION is false for now. We'll flip it after creating our admin account.
  • REQUIRE_SIGNIN_VIEW makes the instance fully private, so even browsing repos requires a login. Set it to false if you want public read access for some projects.
  • All FORGEJO__section__KEY env vars map directly onto entries in app.ini - the same names from the Forgejo configuration cheat sheet.

Step 8: Write the Caddyfile

Create /opt/forgejo/Caddyfile:

git.example.com { encode zstd gzip # Lift the upload size for large repos and LFS pushes. request_body { max_size 1GB } reverse_proxy forgejo:3000 }

Caddy auto-renews the Let's Encrypt cert. No further configuration required.

Step 9: Start the Stack

cd /opt/forgejo sudo docker compose up -d sudo docker compose logs -f

Wait until the Caddy logs show the certificate was issued, then open https://git.example.com in a browser. You'll land on Forgejo's first-run installer.

Almost everything on that screen is already wired up by environment variables, so just confirm:

  • Database type: PostgreSQL
  • Host: postgres:5432
  • The other DB fields are pre-filled

Scroll down to Administrator Account Settings, expand it, and create your first user. The first account on a fresh install becomes the site administrator automatically. Pick a strong password and a real email - you'll need email verification later if you turn it on.

Click Install Forgejo. The container restarts and a few seconds later you're at the dashboard.

Step 10: Disable Open Registrations

Now that your admin user exists, lock the front door:

  1. Edit /opt/forgejo/docker-compose.yml
  2. Change FORGEJO__service__DISABLE_REGISTRATION: "false" to "true"
  3. Apply:
sudo docker compose up -d

To add teammates later, go to Site Administration > Users > Create User Account in the web UI. New users are added by you, not by signup form.

If you'd rather keep public signups but require approval, set DISABLE_REGISTRATION=false and REGISTER_MANUAL_CONFIRM=true. Users can sign up but can't log in until you approve them.

Step 11: Configure SSH for git push and git pull

Forgejo's built-in SSH server is already listening on container port 2222, which we mapped to host 2222 in the compose file. Tell your local SSH client about it.

In your developer machine's ~/.ssh/config:

Host git.example.com HostName git.example.com User git Port 2222 IdentityFile ~/.ssh/id_ed25519

Add your public key in the Forgejo web UI: Settings > SSH / GPG Keys > Add Key. Then test:

ssh -T [email protected]

You should see a friendly Hi <username>! You've successfully authenticated... line.

Cloning works exactly like GitHub:

git clone [email protected]:yourname/your-repo.git

If you skip the ~/.ssh/config entry, just include the port in the remote:

git clone ssh://[email protected]:2222/yourname/your-repo.git

Step 12: Create an Organization

Organizations are how Forgejo groups repos and permissions for teams. From the dashboard:

  1. Click the + in the top right and pick New Organization
  2. Give it a name (this becomes part of every repo URL: git.example.com/myorg/repo)
  3. Pick a visibility (Private is the usual choice for internal work)
  4. Set up teams under Settings > Teams with read, write, or admin scopes

You can move existing repos into the org from each repo's Settings > Transfer Ownership page.

Step 13: Back Up Forgejo

Two things matter for backups: the Forgejo data directory (repos, attachments, config) and the Postgres database (users, issues, PRs). Forgejo ships a forgejo dump command that bundles repo data, app.ini, and a SQL dump of the database into a single archive.

Create /usr/local/bin/forgejo-backup.sh:

#!/usr/bin/env bash set -euo pipefail BACKUP_DIR="/var/backups/forgejo" DATE="$(date +%F)" mkdir -p "$BACKUP_DIR" # Built-in dump: repos, LFS, attachments, app.ini, and a DB dump in one archive. docker exec --user 1000 forgejo \ forgejo dump --type tar.gz --file "/tmp/forgejo-dump-$DATE.tar.gz" --tempdir /tmp docker cp "forgejo:/tmp/forgejo-dump-$DATE.tar.gz" "$BACKUP_DIR/" docker exec forgejo rm -f "/tmp/forgejo-dump-$DATE.tar.gz" # Separate Postgres dump as a belt-and-braces second copy. docker exec forgejo-postgres \ pg_dump -U forgejo -d forgejo \ | gzip > "$BACKUP_DIR/forgejo-pg-$DATE.sql.gz" find "$BACKUP_DIR" -name "forgejo-dump-*.tar.gz" -mtime +14 -delete find "$BACKUP_DIR" -name "forgejo-pg-*.sql.gz" -mtime +14 -delete

Make it executable and schedule it:

sudo chmod +x /usr/local/bin/forgejo-backup.sh echo "15 3 * * * root /usr/local/bin/forgejo-backup.sh" | \ sudo tee /etc/cron.d/forgejo-backup

For off-site safety, push the backup directory to S3 or Backblaze B2 with rclone:

rclone sync /var/backups/forgejo b2:my-bucket/forgejo --progress

To restore on a new server, extract the dump into an empty Forgejo data directory and import the bundled SQL file. The Forgejo restore docs cover the exact layout. The good news is forgejo dump produces everything you need in a single archive.

Step 14: Upgrade Safely

Forgejo follows a calm release cadence with point releases roughly monthly. The safe upgrade path:

cd /opt/forgejo sudo /usr/local/bin/forgejo-backup.sh sudo docker compose pull sudo docker compose up -d

If something looks off, restore the latest dump and pin the previous image tag (e.g. codeberg.org/forgejo/forgejo:9.0.3) in docker-compose.yml.

Optional: Keep Forgejo on a VPN

A private code host doesn't need to be world-reachable. If your team uses Tailscale or WireGuard, point git.example.com to a tailnet IP and stop publishing port 443 to the public internet. Pair this with our Tailscale guide so only your tailnet devices can browse the web UI or git clone.

Troubleshooting

Caddy fails to obtain a certificate. DNS hasn't propagated, or 80/443 are blocked at your provider's firewall. Check with dig +short git.example.com and sudo ss -lntp | grep -E ':(80|443)'.

git clone over SSH hangs. You're hitting the host's sshd instead of Forgejo. Confirm the client is using port 2222: ssh -vT -p 2222 [email protected]. Also check that UFW lets 2222/tcp through.

Permission denied (publickey). The key you added to Forgejo doesn't match the key your client is offering. Run ssh -vT -p 2222 [email protected] to see which key was tried, then re-add the matching public key under Settings > SSH / GPG Keys.

HTTP push fails on large repos. Caddy's default request body limit is too small. The request_body { max_size 1GB } block in the Caddyfile fixes it. Bump higher if you push monorepos.

Forgejo container stuck in a restart loop. Almost always a database connection issue. Check sudo docker compose logs forgejo for dial tcp: connection refused or auth errors. Make sure the values in .env match those Postgres came up with on first boot - if you change the password after the volume is initialized, you have to update Postgres or wipe the data directory.

A handful of useful one-liners:

# Tail Forgejo logs sudo docker compose logs -f forgejo # Open a shell as the forgejo user sudo docker exec -it -u 1000 forgejo bash # Run forgejo admin commands (e.g. reset a password) sudo docker exec -it -u 1000 forgejo \ forgejo admin user change-password --username alice --password 'newPass!' # Print the running config sudo docker exec -it -u 1000 forgejo cat /etc/gitea/app.ini

Going Further

  • Forgejo Actions is GitHub Actions-compatible and ships with Forgejo. Run a Forgejo Actions runner on the same VPS or a spare box and your CI runs alongside your repos.
  • Mirror from GitHub. Under New Migration > GitHub you can import or live-mirror existing repos. Great for a private backup of your open-source work.
  • SSO with OIDC. If you run Authentik or Keycloak, Forgejo accepts OIDC under Site Administration > Authentication Sources. Drop the password form entirely and unify logins with the rest of your stack.
  • LFS. Forgejo supports Git LFS out of the box - flip FORGEJO__lfs__STORAGE_TYPE=local and point it at a roomy disk.
  • Pre-receive hooks for branch protection rules that go beyond the UI options.

That's a private, self-hosted Git server that costs less per month than a single GitHub seat and stays entirely on hardware you control.


Hosting a git server like this is exactly what our Linux VPS plans are built for. NVMe storage, IPv6, and snapshots come standard. See the options.