Skip to content

Instantly share code, notes, and snippets.

@AWolf81
Last active May 5, 2026 18:37
Show Gist options
  • Select an option

  • Save AWolf81/b7fe8c99485e991634cfa376b03188bd to your computer and use it in GitHub Desktop.

Select an option

Save AWolf81/b7fe8c99485e991634cfa376b03188bd to your computer and use it in GitHub Desktop.
Minimal Forgejo setup for a VPS using Docker Compose in Dockge with Traefik & Let's Encrypt

Forgejo on a VPS with Traefik reverse proxy

This is a minimal Forgejo setup for a VPS using Docker Compose, Traefik, Let's Encrypt, and optional Forgejo Actions runner support.

The compose stacks are stored under /opt/stacks so they can also be managed later with Dockge.

Replace every your-domain.com placeholder before deploying.

Target hostnames

Hostname Purpose DNS proxy mode
forgejo.your-domain.com Forgejo web UI Proxied or DNS only
gitssh.your-domain.com Git over SSH on port 2222 DNS only
dockge.your-domain.com Optional Dockge UI Proxied or DNS only

For Cloudflare, keep gitssh.your-domain.com as DNS only. The orange-cloud proxy does not proxy normal SSH traffic unless you use a separate TCP proxy product such as Cloudflare Spectrum.

DNS

Create DNS records that point to your VPS public IP address:

A  forgejo  <VPS_PUBLIC_IPV4>
A  gitssh   <VPS_PUBLIC_IPV4>
A  dockge   <VPS_PUBLIC_IPV4>   # optional, if you use Dockge

Add matching AAAA records only if the VPS really has public IPv6 configured and your firewall allows it.

VPS firewall

Open HTTP, HTTPS, and the Forgejo SSH port:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 2222/tcp
sudo ufw reload

Create the shared Docker network

Both stacks use the same external Docker network:

docker network create proxy

If the network already exists, Docker will print an error. That is fine.

Stack layout

Use Dockge-compatible stack folders:

/opt/stacks/
├── reverse-proxy/
│   ├── compose.yaml
│   └── letsencrypt/
│       └── acme.json
└── forgejo/
    ├── compose.yaml
    ├── .env
    └── data/

Dockge uses /opt/stacks as its stack directory in the common setup. If Dockge is configured with DOCKGE_STACKS_DIR=/opt/stacks, each direct subfolder that contains a compose.yaml can be shown as a stack.

You can still deploy everything with the Docker CLI first. Dockge can pick up the same stack folders later. If Dockge is already running while you add the files, refresh the Dockge UI or restart Dockge if the new stacks do not appear immediately.

Dockge will not automatically start newly copied stacks just because the files exist. Start them with docker compose up -d or from the Dockge UI.

Deploy Traefik first

Create the reverse proxy stack directory:

mkdir -p /opt/stacks/reverse-proxy
cd /opt/stacks/reverse-proxy
# copy reverse-proxy/compose.yaml here
mkdir -p letsencrypt
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json
docker compose up -d

This gist uses Traefik's built-in Let's Encrypt ACME support. That is usually simpler than running Certbot separately.

If you already use Certbot on the VPS, choose one certificate manager only. Do not let Certbot and Traefik ACME manage the same hostnames at the same time. With Certbot, you would normally mount the renewed certificate files into Traefik and configure them through Traefik's file provider instead of enabling the certresolver=le labels.

Deploy Forgejo

Create the Forgejo stack directory:

mkdir -p /opt/stacks/forgejo
cd /opt/stacks/forgejo
# copy forgejo/compose.yaml and forgejo/.env.example here
cp .env.example .env
sed -i "s/change-me-generate-a-long-random-secret/$(openssl rand -hex 32)/" .env
docker compose up -d

Then open:

https://forgejo.your-domain.com

During the first Forgejo setup, verify that the external URL is:

https://forgejo.your-domain.com/

Git over SSH

This compose enables Forgejo's built-in SSH server and publishes it on host port 2222.

Clone URLs should look like this:

git clone ssh://git@gitssh.your-domain.com:2222/USER/REPO.git

If SSH does not work, check:

sudo ufw status numbered
ss -tulpen | grep 2222
docker logs forgejo --tail=100

Forgejo Actions runner

The runner service is included but needs to be registered once before it can work.

In Forgejo, create a runner registration token, then run something like:

cd /opt/stacks/forgejo
docker compose run --rm runner forgejo-runner register \
  --no-interactive \
  --instance http://server:3000 \
  --name docker-runner \
  --token <RUNNER_REGISTRATION_TOKEN> \
  --labels docker:docker://docker:latest

docker compose up -d runner

The runner mounts /var/run/docker.sock, which is convenient but powerful. Treat runner jobs as trusted code or isolate runners on a separate machine.

Production hardening checklist

Before exposing the VPS to the internet, verify that only the required ports are publicly reachable.

Required public ports

This deployment only needs these public ports:

22/tcp     SSH access to the VPS
80/tcp     HTTP for Traefik and Let's Encrypt HTTP challenge
443/tcp    HTTPS through Traefik
2222/tcp   Forgejo Git SSH, only if Git over SSH is enabled

Do not expose application UI ports directly on the VPS IP address.

Dockge

Dockge should be reachable only through the configured HTTPS domain, for example:

https://dockge.your-domain.com

Do not expose Dockge directly on the VPS IP:

http://<vps-ip>:5001

When Dockge is connected to the same Docker network as Traefik, it does not need a public ports: mapping.

Avoid this:

ports:
  - "5001:5001"

Prefer Traefik routing to Dockge's internal container port:

labels:
  - traefik.enable=true
  - traefik.http.routers.dockge.rule=Host(`dockge.your-domain.com`)
  - traefik.http.routers.dockge.entrypoints=websecure
  - traefik.http.routers.dockge.tls.certresolver=le
  - traefik.http.services.dockge.loadbalancer.server.port=5001

Forgejo

Forgejo's web UI should be reachable through Traefik only:

https://forgejo.your-domain.com

Do not expose the Forgejo web UI directly on the VPS IP:

http://<vps-ip>:3000

Avoid this when using Traefik:

ports:
  - "3000:3000"
  - "2222:22"

Prefer exposing only Git SSH, if needed:

ports:
  - "2222:22"

Check Docker-published ports

Run:

docker ps --format "table {{.Names}}\t{{.Ports}}"

A safe result should look similar to this:

forgejo          3000/tcp, 0.0.0.0:2222->22/tcp, [::]:2222->22/tcp
dockge           5001/tcp
traefik          0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp

Avoid public bindings like:

0.0.0.0:3000->3000/tcp
0.0.0.0:5001->5001/tcp
[::]:3000->3000/tcp
[::]:5001->5001/tcp

Check UFW rules

Run:

sudo ufw status numbered

Expected public rules:

22/tcp
80/tcp
443/tcp
2222/tcp

Remove rules for direct application ports such as:

3000/tcp
5001/tcp

Delete UFW rules by number:

sudo ufw delete <rule-number>

Delete higher numbered rules first because UFW renumbers rules after each deletion.

Verify from outside the VPS

Run this from your local machine, not from the VPS:

nmap -Pn -p 22,80,443,2222,3000,5001 <vps-ip-address>

Expected result:

22/tcp    open
80/tcp    open
443/tcp   open
2222/tcp  open, only if Forgejo Git SSH is used
3000/tcp  closed or filtered
5001/tcp  closed or filtered

Final check

  • Forgejo web UI is reachable only through the HTTPS domain.
  • Dockge is reachable only through the HTTPS domain, if installed.
  • Port 3000 is not publicly exposed.
  • Port 5001 is not publicly exposed.
  • Only 22, 80, 443, and optionally 2222 are publicly reachable.
  • docker ps shows no unwanted 0.0.0.0:<port> bindings.
  • ufw has no allow rules for direct application ports.
  • External nmap confirms direct application ports are closed or filtered.

Dockge note

Dockge works well for managing these Compose stacks on a VPS because it gives you a simple web UI to start, stop, inspect, and edit Docker Compose stacks without logging in over SSH for every change.

Dockge setup guide: https://ramnode.com/guides/dockge

Deploy the reverse proxy stack first, then add Forgejo as a separate stack that joins the existing proxy network. If Dockge is configured with /opt/stacks, it can pick up both /opt/stacks/reverse-proxy/compose.yaml and /opt/stacks/forgejo/compose.yaml later.

If Dockge is exposed publicly, put it behind HTTPS, use a strong password, and avoid leaving the raw Dockge port open to the internet.

# add the following env to dockge
# OAUTH2_SECRET=change-me-generate-a-long-random-secret
services:
server:
image: codeberg.org/forgejo/forgejo:14
container_name: forgejo
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__server__DOMAIN=forge.your-domain.com
- FORGEJO__server__ROOT_URL=https://forgejo.your-domain.com/
- FORGEJO__server__SSH_DOMAIN=gitssh.your-domain.com
- FORGEJO__server__START_SSH_SERVER=true
- FORGEJO__server__SSH_LISTEN_PORT=22
- FORGEJO__server__SSH_PORT=2222
- FORGEJO__oauth2__JWT_SECRET=${OAUTH2_SECRET}
- FORGEJO__actions__ENABLED=true
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- 2222:22
# Optional local-only debug access. Traefik does not need this.
# - 127.0.0.1:3000:3000
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.forgejo.rule=Host(`forgejo.your-domain.com`)
- traefik.http.routers.forgejo.entrypoints=websecure
- traefik.http.routers.forgejo.tls.certresolver=le
- traefik.http.services.forgejo.loadbalancer.server.port=3000
networks:
- proxy
runner:
image: code.forgejo.org/forgejo/runner:3.5.0
container_name: forgejo-runner
restart: unless-stopped
depends_on:
- server
environment:
- FORGEJO_INSTANCE_URL=http://server:3000
volumes:
- ./runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
command: forgejo-runner daemon --config /data/.runner
networks:
- proxy
networks:
proxy:
external: true
services:
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
- --certificatesresolvers.le.acme.email=admin@your-domain.com
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --accesslog=true
- --log.level=INFO
ports:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
networks:
- proxy
networks:
proxy:
external: true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment