How to Self-Host Forgejo with Docker
Forgejo is a self-hosted software forge for Git repositories, issues, pull requests, packages, and related development work. Its canonical repository is on Codeberg, the project documentation is at forgejo.org, and the code is licensed under GPL-3.0-or-later. Check releases and report bugs there rather than on a GitHub mirror.
This tutorial deploys Forgejo 16.0.0, the current release when this article was checked on July 17, 2026, on a fresh Debian or Ubuntu VPS. It uses the standard rootful Docker image and SQLite. Caddy terminates HTTPS for git.example.com, the web listener stays on 127.0.0.1:3000, and Git SSH uses public host port 2222. The path includes an actual clone, commit, and push rather than stopping when the dashboard loads.
SQLite suits a straightforward forge with low-to-moderate activity. Choose PostgreSQL before launch if you expect high concurrency or already operate it well. Changing databases also changes the backup and restore procedure.
Forgejo is one of ten projects in our broader guide to self-hosted apps for a VPS. That guide compares the candidates; this article covers the full deployment path for one of them.
Prerequisites
Prepare the following:
- a Debian or Ubuntu VPS with sudo or root access;
- an A record for
git.example.compointing to the VPS, and an AAAA record only when IPv6 is configured correctly; - TCP 80 and 443 for the web proxy, TCP 2222 for Forgejo Git SSH, and your separate administrative SSH port;
- Git and an SSH client on your workstation;
- off-server storage large enough for repositories, SQLite, configuration, LFS objects, packages, and attachments.
Harden administrative access first with the secure SSH guide. This tutorial assumes the host’s own SSH daemon remains on port 22, so Forgejo can use 2222 without a collision. If you moved your administrative sshd to 2222, for example by following that guide’s worked example, pick a different host port for Forgejo’s Git SSH, such as 2223, and use it consistently in every firewall rule, port mapping, and clone URL below.
Install Docker Engine and the Compose plugin using Docker’s convenience script (read through it first if you want to see what it will change):
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo docker version
Because the script runs as root and adds Docker’s package repository, hosts with stricter policies should install Docker from the distribution’s packages instead. As of July 2026, Forgejo’s upgrade documentation requires Docker 20.10.6 or newer, which the version output confirms.
Install Caddy, Git, and UFW from the distro repository. See Caddy’s official package instructions if your distribution needs its upstream repository.
sudo apt update
sudo apt install -y caddy git ufw
Set the firewall before starting the forge:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 2222/tcp
sudo ufw enable
Replace 22 if your administrative SSH listener uses another port. Mirror these rules in the provider’s cloud firewall or security group. Do not open port 3000 or a database port publicly.
Create the Forgejo deployment
The standard image stores its state under /data. Create a host directory owned by the UID and GID used in the Compose file:
sudo install -d -m 750 -o 1000 -g 1000 /opt/forgejo/forgejo
cd /opt/forgejo
Create /opt/forgejo/compose.yml. The layout follows Forgejo’s official Docker and configuration documentation; the release pin, loopback web binding, public port 2222, disabled registration, and log limits are choices made for this tutorial.
sudo tee compose.yml > /dev/null <<'YAML'
services:
forgejo:
image: codeberg.org/forgejo/forgejo:16.0.0
container_name: forgejo
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__DOMAIN: "git.example.com"
FORGEJO__server__ROOT_URL: "https://git.example.com/"
FORGEJO__server__SSH_DOMAIN: "git.example.com"
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__service__DISABLE_REGISTRATION: "true"
volumes:
- ./forgejo:/data
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000"
- "2222:22"
logging:
options:
max-size: "10m"
max-file: "3"
YAML
The image is pinned to the exact release checked for this article. An exact tag prevents an unattended major migration, but it also stops automatic fixes, so read the canonical Forgejo releases and update the tag deliberately later.
Forgejo converts variables named FORGEJO__[SECTION]__[KEY] into app.ini settings. ROOT_URL controls browser links and HTTPS clone URLs. SSH_DOMAIN and SSH_PORT make displayed SSH clone URLs use git.example.com:2222, while Docker forwards that host port to port 22 inside the standard container.
The ./forgejo:/data mount contains app.ini, repositories, the SQLite database, LFS objects, attachments, packages, SSH-related state, and generated secrets. In particular, losing [security].SECRET_KEY can make encrypted data such as 2FA secrets unreadable.
This guide uses the standard image. It does not teach the rootless variant because its paths and port mappings differ. Validate those details against the exact image before designing a rootless deployment.
Start the service, wait for the web listener, then confirm port 3000 is loopback-only and port 2222 is public:
cd /opt/forgejo
sudo docker compose up -d
for attempt in $(seq 1 24); do
if curl -fsS -o /dev/null http://127.0.0.1:3000/; then
break
fi
if [ "$attempt" -eq 24 ]; then
echo "Forgejo did not become ready within 120 seconds" >&2
exit 1
fi
sleep 5
done
sudo ss -lntp | grep -E ':(3000|2222)'
Add Caddy and HTTPS
Use a dedicated subdomain. Forgejo’s reverse-proxy documentation warns against putting a forge below a subpath on an origin that also serves user-controlled content because same-origin and CSRF boundaries become harder to protect.
Create an imported Caddy site snippet. This keeps existing hosts intact even when Forgejo is not the first service on the VPS:
sudo install -d -m 755 /etc/caddy/sites-enabled
sudo tee /etc/caddy/sites-enabled/forgejo.caddy > /dev/null <<'CADDY'
git.example.com {
reverse_proxy 127.0.0.1:3000
}
CADDY
if ! sudo grep -Eq \
'^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*' \
/etc/caddy/Caddyfile; then
printf '\nimport /etc/caddy/sites-enabled/*\n' | \
sudo tee -a /etc/caddy/Caddyfile > /dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Caddy requests and renews the certificate when DNS and both firewalls permit TCP 80/443. Git SSH does not pass through Caddy. Clients connect directly to port 2222.
Complete the first-run installer
An uninitialized public forge can be claimed by whoever reaches its installer first. Open https://git.example.com only when you are ready to finish setup. For stricter staging, keep Caddy stopped and tunnel the loopback listener with ssh -L 3000:127.0.0.1:3000 root@SERVER_IP, then browse to http://127.0.0.1:3000.
In the installer:
- Select SQLite3 and keep its database inside
/data. - Confirm the server domain is
git.example.com. - Confirm the Forgejo base URL is
https://git.example.com/. - Confirm the SSH domain is
git.example.comand the SSH port is2222. - Expand the administrator section and create the first administrator with a strong unique password and a real email address.
- Finish the installation and sign in.
The first user created during installation receives administrator access. Registration is already disabled through Compose. Verify that a logged-out visitor cannot register, then enable 2FA on the administrator account. Configure SMTP before depending on password reset, email confirmation, or notifications.
Verify HTTPS and SSH Git workflows
Run the server-side checks:
cd /opt/forgejo
curl -fsSI https://git.example.com/
sudo docker compose exec forgejo \
forgejo doctor check --all --log-file /tmp/doctor.log
sudo docker compose exec forgejo cat /tmp/doctor.log
In the web UI, create a disposable repository named docker-test under your account. Leave it empty for the first clone. Forgejo’s HTTP clone path uses HTTPS here, so credentials and repository data are protected in transit. On your workstation, replace YOUR_USER with the account name:
git clone https://git.example.com/YOUR_USER/docker-test.git
cd docker-test
git config user.name "Forgejo Test"
git config user.email "[email protected]"
printf '%s\n' 'Forgejo end-to-end test' > README.md
git add README.md
git commit -m "Test HTTPS push"
git branch -M main
git push -u origin main
Enter the Forgejo username and password or personal access token when Git prompts. Refresh the browser and confirm the commit and file appear. This proves that the reverse proxy, application, SQLite database, and repository storage agree.
Now test SSH. Add your workstation’s public SSH key under Settings > SSH/GPG Keys, then run:
ssh -F /dev/null -p 2222 [email protected]
Forgejo should recognize the account and deny interactive shell access. Change the existing clone to SSH and push a second commit:
git remote set-url origin \
ssh://[email protected]:2222/YOUR_USER/docker-test.git
printf '%s\n' 'SSH push works' >> README.md
git add README.md
git commit -m "Test SSH push"
git push
Confirm the second commit in the web UI. Delete the disposable repository after the backup test if you do not need it.
Plan for storage and runners
Git history is only part of forge storage. Git LFS, release attachments, package registries, repository mirrors, and Actions artifacts can grow independently. Set quotas where appropriate and monitor the filesystem instead of estimating capacity from repository count.
Run Forgejo Actions runners as separate services with their own lifecycle and isolation. Workflow code may be controlled by repository contributors. Do not attach a runner to the Forgejo container or casually give it the host Docker socket; that can turn a CI job into host-level access. Follow the project’s separate Actions security documentation before enabling untrusted workflows.
Back up and rehearse restoration
Forgejo’s official guidance requires a synchronized point-in-time copy of every storage component. For this single-volume SQLite design, a stopped-container archive of /data captures the database, repositories, configuration, packages, LFS objects, and generated secrets together:
sudo install -d -m 700 /var/backups/forgejo
cd /opt/forgejo
sudo docker compose exec forgejo forgejo manager flush-queues
sudo docker compose stop forgejo
sudo tar --numeric-owner -C /opt/forgejo -czf \
"/var/backups/forgejo/forgejo-$(date +%F-%H%M%S).tar.gz" \
forgejo
sudo docker compose start forgejo
for attempt in $(seq 1 24); do
if curl -fsS -o /dev/null http://127.0.0.1:3000/; then
break
fi
if [ "$attempt" -eq 24 ]; then
echo "Forgejo did not restart within 120 seconds" >&2
exit 1
fi
sleep 5
done
Encrypt and copy the archive off-host. If you move to PostgreSQL or MySQL, stop or quiesce Forgejo and take the database vendor’s native dump as part of the same recovery point. External object storage and remote repository paths must be captured with it. forgejo dump can be useful, but the official upgrade guide warns about restoring its SQL output for external databases; do not treat it as a substitute for pg_dump or mysqldump.
Restore onto an isolated test VPS with Forgejo 16.0.0. Transfer one archive, set BACKUP to its real path, and keep the empty directory as a rollback point:
export BACKUP=/path/to/forgejo-YYYY-MM-DD-HHMMSS.tar.gz
cd /opt/forgejo
sudo docker compose stop forgejo
sudo mv forgejo forgejo.empty
sudo tar --numeric-owner -C /opt/forgejo -xzf "$BACKUP"
sudo chown -R 1000:1000 /opt/forgejo/forgejo
sudo docker compose up -d
for attempt in $(seq 1 24); do
if curl -fsS -o /dev/null http://127.0.0.1:3000/; then
break
fi
if [ "$attempt" -eq 24 ]; then
echo "Restored Forgejo did not become ready within 120 seconds" >&2
exit 1
fi
sleep 5
done
sudo docker compose exec forgejo \
forgejo doctor check --all --log-file /tmp/doctor.log
sudo docker compose exec forgejo cat /tmp/doctor.log
Log in, clone the restored docker-test repository over HTTPS, push a commit over SSH, and check any LFS objects, packages, or attachments you use. Upgrade only after the restored copy passes those checks.
Every Riven Cloud plan includes daily provider backups, which make a good extra layer, but they are no substitute for an application-consistent Forgejo backup and a rehearsed restore, so keep both.
Upgrade deliberately
Read the release notes and known upgrade issues, take a consistent backup, then flush queues:
cd /opt/forgejo
sudo docker compose exec forgejo forgejo manager flush-queues
Change 16.0.0 in compose.yml to the reviewed release and run:
sudo docker compose pull
sudo docker compose up -d
for attempt in $(seq 1 24); do
if curl -fsS -o /dev/null http://127.0.0.1:3000/; then
break
fi
if [ "$attempt" -eq 24 ]; then
echo "Upgraded Forgejo did not become ready within 120 seconds" >&2
exit 1
fi
sleep 5
done
sudo docker compose exec forgejo \
forgejo doctor check --all --log-file /tmp/doctor.log
sudo docker compose exec forgejo cat /tmp/doctor.log
Repeat login, HTTPS clone, SSH push, and storage checks. Major upgrades require an explicit tag change and human verification. Forgejo performs database migrations at startup; do not assume a downgrade will be safe after those migrations.
Choosing a Riven Cloud VPS for Forgejo
For a small private forge with ordinary source repositories, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB transfer) is a solid start. Storage is usually the first thing you outgrow: Git LFS, packages, release assets, mirrors, and CI artifacts add up much faster than plain Git history. When they do, Ultra raises the NVMe to 80 GB and transfer to 2 TB, and Max provides 160 GB and 4 TB. Watch retained data and clone traffic rather than sizing by repository count, and keep Actions runners on separately isolated capacity.
All plans include a 1 Gbps port, full root access, daily backups, and deployment in Tokyo or Singapore. Our plans are unmanaged, so you keep full control of the stack you just built. Put the forge near the developers and systems that clone, push, fetch packages, and receive webhooks; teams connecting from mainland China can compare the Tokyo Looking Glass and Singapore Looking Glass from the office or carrier network to see which location feels closer. The pricing page lists the current storage and transfer limits.
Security limits
Keep public registration disabled unless you can handle spam, abuse reports, mail reputation, and user storage. If you open it, use Forgejo’s recommended CAPTCHA and username-cooldown controls. Protect administrator accounts with 2FA, review webhook targets and secrets, and leave local-network access for repository migrations disabled unless you have a specific reason to permit it.
Third-party themes, Git hooks, templates, extensions, and runners execute or influence trusted server-side work. Add them only with a review and backup plan.
When not to self-host
If source availability is business-critical and nobody on the team wants to own upgrades, monitoring, mail, incident response, and restore tests, a managed forge will serve you better. A single VPS may also fall short of compliance or geographic-redundancy requirements. And if you need to run untrusted workflow code but cannot isolate runners from the forge and host, managed CI is the safer choice.
Share