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.
TL;DR
- Install Docker and Docker Compose on a fresh VPS
- Point a subdomain like
git.example.comat 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
2222to avoid colliding with the host'ssshd - Create the first admin account, disable open signups, build an org
- Schedule a daily
forgejo dumpplus apg_dumpto 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, and2222open 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.
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 (
3000inside the container) is not published to the host. Caddy reaches it over the Docker network. - Port
2222is published to the host so Git clients can connect withssh://[email protected]:2222/.... FORGEJO__service__DISABLE_REGISTRATIONisfalsefor now. We'll flip it after creating our admin account.REQUIRE_SIGNIN_VIEWmakes the instance fully private, so even browsing repos requires a login. Set it tofalseif you want public read access for some projects.- All
FORGEJO__section__KEYenv vars map directly onto entries inapp.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:
- Edit
/opt/forgejo/docker-compose.yml - Change
FORGEJO__service__DISABLE_REGISTRATION: "false"to"true" - 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:
- Click the + in the top right and pick New Organization
- Give it a name (this becomes part of every repo URL:
git.example.com/myorg/repo) - Pick a visibility (Private is the usual choice for internal work)
- 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=localand 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.