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.
git.example.com at your serverdocker-compose.yml4432222 to avoid colliding with the host's sshdforgejo dump plus a pg_dump to a separate volumeTotal time: about 15 minutes.
80, 443, and 2222 open to the internetForgejo 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.
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.
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
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.
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.
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.
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
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:
3000 inside the container) is not published to the host. Caddy reaches it over the Docker network.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.FORGEJO__section__KEY env vars map directly onto entries in app.ini - the same names from the Forgejo configuration cheat sheet.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.
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:
PostgreSQLpostgres:5432Scroll 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.
Now that your admin user exists, lock the front door:
/opt/forgejo/docker-compose.ymlFORGEJO__service__DISABLE_REGISTRATION: "false" to "true"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.
git push and git pullForgejo'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
Organizations are how Forgejo groups repos and permissions for teams. From the dashboard:
git.example.com/myorg/repo)You can move existing repos into the org from each repo's Settings > Transfer Ownership page.
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.
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.
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.
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
FORGEJO__lfs__STORAGE_TYPE=local and point it at a roomy disk.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.