I self-hosted Forgejo on Ubuntu 24.04 — clone URLs lied until ROOT_URL was right

I self-hosted Forgejo on Ubuntu 24.04 — clone URLs lied until ROOT_URL was right

I wanted a community-led Git forge without GitHub for private repos. Forgejo installed like Gitea; git clone still hit the wrong SSH daemon until ROOT_URL, SSH_PORT 222, and UFW agreed. After that, push and pull requests worked.

· Updated · 4 min read #self-hosted #open-source #forgejo #git #code-hosting #go #deployment #docker #vps

 Forgejo self-hosted Git forge overview

Caption: Forgejo on my VPS: repos, issues, PRs, releases, packages, SSH on a dedicated port.

Why I wanted this on my server

I already knew Gitea. I wanted the same small forge with Forgejo’s community-led governance — a hard fork, same collaboration flow, my backups. Homelab Compose files, client repos, and deploy manifests stay on this VPS. I did not want another SaaS holding deploy keys. Agencies can keep client repositories under their own retention rules. Product teams can host internal packages and release artifacts without a GitHub org bill.

Forgejo can hold production manifests, customer project history, webhook URLs, and runner logs. I treat it like core infrastructure: HTTPS, registration off, backups I have restored once, CI runners outside the forge’s trust boundary.

What I actually installed

Ubuntu 24.04 LTS, Docker Compose, codeberg.org/forgejo/forgejo:16, PostgreSQL 16, Nginx + Let’s Encrypt, data under /opt/forgejo. Site: https://git.example.com. Web on 127.0.0.1:3000. Container SSH on host port 222.

Hardware: 1 vCPU / 1 GB for personal use; 2 vCPU / 2–4 GB for a small team, package registry, or frequent indexing. SSD for Git objects, attachments, avatars, packages, indexes, and Postgres. Off-server backups. Actions runners go on a separate host if I ever enable them — untrusted jobs should not share this Docker daemon.

Security I would not skip: HTTPS before importing repositories, .env and dumps private, registration off, SSH on 222 unless the host is designed around Forgejo owning 22, and both database plus data directory backed up before upgrades.

Where it broke

On a fresh Ubuntu 24.04 box this install fails the same way Gitea does: the UI copy-paste SSH URL ignores your published port.

I mapped "222:22" so host OpenSSH could keep 22. The clone button still looked like GitHub-style git@git.example.com:owner/repo.git. My laptop connected to Ubuntu’s sshd, not Forgejo. ssh -T git@git.example.com was the wrong process.

Fix: set FORGEJO__server__ROOT_URL, SSH_DOMAIN, and SSH_PORT=222, open 222/tcp in UFW, clone with an explicit port:

ssh -F /dev/null -T -p 222 git@git.example.com
git clone ssh://git@git.example.com:222/your-user/test-repo.git

Config lives in app.ini at a path that still says gitea:

docker compose exec server bash -lc 'grep -E "ROOT_URL|SSH_DOMAIN|SSH_PORT|DISABLE_REGISTRATION|COOKIE_SECURE" /data/gitea/conf/app.ini'

That path is not a typo on my part — the image still uses /data/gitea/conf/app.ini. I grepped /data/forgejo/ first and found nothing.

Second failure: cannot write /data. USER_UID / USER_GID must match the owner of /opt/forgejo/forgejo. Permission errors in server logs are almost always that.

Other documented gotchas: database connection — docker compose logs db and matching .env passwords. 413 uploads — Nginx client_max_body_size. Registration policy — DISABLE_REGISTRATION in app.ini after Compose changes. SMTP silent — protocol, host, port, From, user, password, server logs. Actions jobs that never run — runner installed, registered, online, labels matching the workflow. Incomplete backups — restore on a spare host and check users, repos, issues, PRs, attachments, releases, packages, and settings. FORGEJO__section__KEY env vars rewrite app.ini on boot, same trap as Gitea: a hand-edit can get overwritten.

The web port stays on loopback. If I wanted Git SSH on 22, I would plan host OpenSSH first. I did not.

 Forgejo Docker Compose stack

Caption: HTTPS on Nginx, Forgejo on loopback 3000, SSH on 222, Postgres for metadata.

The working install

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg openssl ufw nginx certbot python3-certbot-nginx jq git rsync postgresql-client

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

1. Install Docker Engine

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

2. Create the project directory

sudo mkdir -p /opt/forgejo/{forgejo,postgres,backups}
sudo chown -R "$USER":"$USER" /opt/forgejo
chmod 700 /opt/forgejo
cd /opt/forgejo

3. Generate environment values

cd /opt/forgejo

FORGEJO_DB_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')"
cat > .env <<EOF
FORGEJO_DOMAIN=git.example.com
FORGEJO_ROOT_URL=https://git.example.com/
FORGEJO_SSH_PORT=222
FORGEJO_DB_PASSWORD=${FORGEJO_DB_PASSWORD}
POSTGRES_PASSWORD=${FORGEJO_DB_PASSWORD}
EOF

chmod 600 .env

4. Write Docker Compose configuration

networks:
  forgejo:
    external: false

services:
  server:
    image: codeberg.org/forgejo/forgejo:16
    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=${FORGEJO_DB_PASSWORD}
      - FORGEJO__server__DOMAIN=${FORGEJO_DOMAIN}
      - FORGEJO__server__SSH_DOMAIN=${FORGEJO_DOMAIN}
      - FORGEJO__server__SSH_PORT=${FORGEJO_SSH_PORT}
      - FORGEJO__server__ROOT_URL=${FORGEJO_ROOT_URL}
      - FORGEJO__service__DISABLE_REGISTRATION=true
      - FORGEJO__service__REQUIRE_SIGNIN_VIEW=false
      - FORGEJO__repository__DEFAULT_PRIVATE=last
      - FORGEJO__session__COOKIE_SECURE=true
    networks:
      - forgejo
    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:
        condition: service_healthy

  db:
    image: docker.io/library/postgres:16
    container_name: forgejo-db
    restart: always
    environment:
      - POSTGRES_USER=forgejo
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=forgejo
    networks:
      - forgejo
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U forgejo -d forgejo"]
      interval: 10s
      timeout: 5s
      retries: 5
docker compose config
docker compose pull
docker compose up -d
docker compose ps
docker compose logs -f --tail=100 server

Wait for a clean start. Configure Nginx before onboarding over the public internet.

5. Configure Nginx and HTTPS

sudo tee /etc/nginx/sites-available/forgejo >/dev/null <<'EOF'
server {
    listen 80;
    server_name git.example.com;

    client_max_body_size 512m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}
EOF

sudo ln -s /etc/nginx/sites-available/forgejo /etc/nginx/sites-enabled/forgejo
sudo nginx -t
sudo systemctl reload nginx

sudo certbot --nginx -d git.example.com
curl -I https://git.example.com/

6. Complete first-run onboarding

Open https://git.example.com/. Database: PostgreSQL, db:5432, user/db forgejo, password from .env. Domain and base URL HTTPS. SSH port 222. Create the admin; registration is disabled in Compose.

Configuration

FORGEJO__section__KEY maps into app.ini.

 Forgejo security configuration map

Caption: Public URLs, SSH port, registration, cookies, SMTP, runner boundary.

[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
ENABLE_NOTIFY_MAIL = true

[repository]
DEFAULT_PRIVATE = private

[server]
ROOT_URL = https://git.example.com/
SSH_DOMAIN = git.example.com
SSH_PORT = 222

[session]
COOKIE_SECURE = true

SMTP:

services:
  server:
    environment:
      - FORGEJO__mailer__ENABLED=true
      - FORGEJO__mailer__PROTOCOL=smtps
      - FORGEJO__mailer__SMTP_ADDR=smtp.example.com
      - FORGEJO__mailer__SMTP_PORT=465
      - FORGEJO__mailer__FROM=git@example.com
      - FORGEJO__mailer__USER=git@example.com
      - FORGEJO__mailer__PASSWD=${FORGEJO_SMTP_PASSWORD}
docker compose up -d
docker compose logs --tail=100 server

Forgejo Runner is a separate install. I would not put an untrusted runner on the same host as the forge, Postgres, or backups.

Important information

forgejo dump is not a full disaster recovery by itself on a Postgres-backed install. The upgrade guide wants a dump plus a separate pg_dump. I almost skipped the SQL gzip because the zip looked complete.

Usage

  1. Daily user (not only admin), SSH key, private test repo.
  2. Clone with port 222, push, issue, pull request.
  3. Confirm anonymous browse matches your policy.
ssh -F /dev/null -T -p 222 git@git.example.com

git clone ssh://git@git.example.com:222/your-user/test-repo.git
cd test-repo
printf '# Test Repository\n' > README.md
git add README.md
git commit -m "docs: add readme"
git push origin main
git remote add forgejo ssh://git@git.example.com:222/your-user/project.git
git push forgejo main
git push forgejo --tags

 Forgejo backup and restore workflow

Caption: Postgres, forgejo dump zip, data tarball, off-server copy.

Backup, expose, next step

sudo tee /opt/forgejo/backup.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

cd /opt/forgejo
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p backups

docker compose exec -T db pg_dump -U forgejo -d forgejo | gzip > "backups/forgejo-db-${stamp}.sql.gz"
docker compose exec -T server forgejo dump --type zip --file - > "backups/forgejo-dump-${stamp}.zip"
tar --one-file-system -czf "backups/forgejo-data-${stamp}.tar.gz" forgejo

find backups -type f -mtime +14 -delete
EOF

sudo chmod 700 /opt/forgejo/backup.sh
/opt/forgejo/backup.sh
rsync -avz /opt/forgejo/backups/ backup-user@backup.example.net:/srv/backups/forgejo/
cd /opt/forgejo
/opt/forgejo/backup.sh
docker compose pull
docker compose down
docker compose up -d
docker compose logs --tail=120 server

Pin codeberg.org/forgejo/forgejo:16. Read the upgrade guide before a major tag bump.

What I have running now: Forgejo 16 on HTTPS, registration off, SSH on 222, Postgres, a backup script that dumps SQL, forgejo dump, and the data directory. I migrate one repository at a time after clone URLs look right. Next: SMTP, disk monitoring, a restore on a spare host, and an upgrade log before the next major tag. Runners stay off this machine. Pin codeberg.org/forgejo/forgejo:16 and read the upgrade guide before bumping the series.

Did you hit the same wall?

I got stuck on SSH clones hitting host port 22, and on grepping /data/forgejo/ for app.ini when the file is still /data/gitea/conf/app.ini. Did you hit the same thing, or a different one — UID/GID on /data, 413 uploads, runner labels? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.