Forgejo + Runner: The Bombproof and Leakproof Self-Hosted Git (and Hardening)
Forgejo: The Lightness of Git
If we want a personal Git server, we basically have three choices. The first is to use GitHub or GitLab, gifting our data to third parties. The second is to spin up self-hosted GitLab, provided you have a dedicated server with 8GB of RAM ready to be swallowed up on the first boot. The third path, the only sensible one, is called Forgejo (a community-driven fork of Gitea).
It is written in Go, consumes very little, and is incredibly complete. But a Git server without a CI/CD pipeline (continuous integration and deployment) is only half the job. That's why we need to couple it with an official runner: forgejo-runner.
In this article, we will look at how to deploy Forgejo via Docker and how to configure a native runner on the host with some healthy Systemd hardening, which is excellent if we need to compile static sites (such as with Zola or Hugo) and place them directly in the server's /var/www/ without doing horrible hacks with Docker permissions.
Step 1: Deploying Forgejo (Docker Compose + Secured Postgres)
For a decent production environment, let's leave SQLite alone and opt for a serious relational database like PostgreSQL, isolated in a private network that is not accessible from the outside.
As usual, to keep the environment clean and easily backuppable, we use Docker Compose with relative volumes. I have always had a strong aversion to named volumes: with relative volumes, we just need to make a tarball of the folder when services are stopped, and the backup is done.
Create the directory for our Forgejo instance:
mkdir -p /opt/forgejo
cd /opt/forgejoTo avoid leaving passwords hardcoded in the YAML file, let's create a .env file containing the compose project name and a strong, randomly generated database password:
COMPOSE_PROJECT_NAME=dreadful_forgejo
FORGEJO_DATABASE_PASSWORD="an_extremely_long_and_complex_secret"Now, write our docker-compose.yml defining both the isolated backend network for Postgres and the external network to expose Forgejo to the reverse proxy (or the Tor gateway):
services:
server:
image: codeberg.org/forgejo/forgejo:15.0.3
container_name: forgejo-server
restart: always
environment:
- TZ=Europe/Rome
- FORGEJO__database__DB_TYPE=postgres
- FORGEJO__database__HOST=database:5432
- FORGEJO__database__NAME=forgejo
- FORGEJO__database__USER=forgejo
- FORGEJO__database__PASSWD=${FORGEJO_DATABASE_PASSWORD}
networks:
- forgejo-backend # Isolated network to speak with the DB
- forgejo_net # External network to receive traffic from Tor/Proxy
volumes:
- ./data:/data
ports:
- 3205:3000
depends_on:
- database
database:
image: postgres:18
container_name: forgejo-database
restart: always
environment:
- POSTGRES_DB=forgejo
- POSTGRES_USER=forgejo
- POSTGRES_PASSWORD=${FORGEJO_DATABASE_PASSWORD}
networks:
- forgejo-backend # The database lives ONLY in here, zero clearnet
volumes:
- ./postgres:/var/lib/postgresql/data # Persistence of raw Postgres data
networks:
# Ad-hoc created and secured backend network
forgejo-backend:
internal: true
# Pre-existing external network for reverse proxy or Tor gateway
forgejo_net:
external: trueThe attentive eye will notice two fundamental things:
- The Postgres database has no exposed ports (
ports:) and is only associated with theforgejo-backendnetwork marked asinternal: true. This guarantees total isolation from the internet and leak prevention. - The Postgres volume mounts
./postgres:/var/lib/postgresql/data(note the final/data) to ensure correct persistence according to the official image specs.
Bring up the infrastructure with the usual command:
docker compose up -dAt this point, by accessing the browser at http://[SERVER-IP]:3205, we can complete the initial setup (very guided and intuitive) and register our admin user.
Step 2: Register the Runner in the Instance
Before starting the runner, we must tell it how to speak to Forgejo. Access the web interface as admin:
- Go to Site Administration -> Actions -> Runners.
- Click Create new runner.
- The screen will show two vital pieces of information that we won't see again: an UUID and a registration Token, as well as a pre-configured YAML block. Copy these values.
Step 3: Configure the Runner on the Host
To run deployment commands directly on the machine (for example, to move files to /var/www/ or launch local commands), we will install forgejo-runner directly on the host, running it under a dedicated user without administrative privileges.
1. Create the User
sudo useradd -m -s /bin/bash forgejo-runner
sudo mkdir -p /home/forgejo-runner/.forgejo-runner2. Generate Configuration
Download the forgejo-runner executable for our architecture, copy it to /usr/local/bin/forgejo-runner, and make it executable. Then generate the basic configuration file:
sudo -u forgejo-runner forgejo-runner generate-config > /home/forgejo-runner/.forgejo-runner/config.yaml3. Modify the config.yaml File
Open /home/forgejo-runner/.forgejo-runner/config.yaml and modify two key blocks.
The first is the server block, where we paste the details from the Forgejo panel:
server:
connections:
my-git-server:
url: http://127.0.0.1:3205 # Forgejo address (local or onion)
uuid: 3a85d7d2-e97d-4069-a71c-e573adc5e31d # Our UUID
token: dcbc6dc71365d4a74b2fa0e1d8f6c91652f15ad3 # Our Token
labels:
- "ubuntu-latest:host" # <--- CRITICAL: tells the runner to run jobs directly on the hostThe attentive eye will notice that we inserted ubuntu-latest:host under the connection labels. By default, the runner would try to launch jobs inside Docker containers. By mapping it as :host, when we write runs-on: ubuntu-latest in our workflows, the runner will execute the instructions as simple bash commands on the actual server.
Fix file ownership:
sudo chown -R forgejo-runner:forgejo-runner /home/forgejo-runner/Step 4: Systemd Service (With a Healthy Dose of Hardening)
Running commands directly on the host poses security risks. To mitigate this, we create a hardened Systemd service using Linux kernel security features and resource allocation controls to prevent unauthorized network scanning or DoS attacks.
Create the file /etc/systemd/system/forgejo-runner.service:
[Unit]
Description=Forgejo Runner Service
After=network.target
[Service]
Type=simple
User=forgejo-runner
Group=forgejo-runner
WorkingDirectory=/home/forgejo-runner/.forgejo-runner
ExecStart=/usr/local/bin/forgejo-runner daemon --config /home/forgejo-runner/.forgejo-runner/config.yaml
Restart=always
RestartSec=10
# --- RESOURCE LIMITS (Anti-DoS) ---
# Limit CPU usage to 50% of one core (adjust as needed)
CPUQuota=50%
# Limit max RAM allocatable by the runner
MemoryMax=1G
# Limit max threads/processes (prevents fork-bombs in workflow)
TasksMax=100
# --- NETWORK LIMITS (Anti-PortScan/Leak) ---
# Allow only connections to localhost (necessary to speak with local Forgejo)
IPAddressAllow=localhost
# Deny all other network traffic (LAN, VPN, WAN)
IPAddressDeny=any
# --- FILE SYSTEM & KERNEL HARDENING ---
NoNewPrivileges=true
ProtectSystem=strict
# Grant write access ONLY to the runner's directory and the website's webroot
ReadWritePaths=/home/forgejo-runner/.forgejo-runner /var/www/my-static-site.it/
# Isolate the homes of other real users on the server
ProtectHome=tmp
InaccessiblePaths=/home/my-admin-user
# Limit access to hardware, cgroups, and kernel tuning features
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
ProtectKernelModules=true
RestrictRealtime=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.targetEnable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner.serviceStep 5: Testing the Workflow (and a Lifesaving Tip)
Now we can create a deployment pipeline by creating the file .forgejo/workflows/deploy.yml inside our repository.
If we use runners in host mode with absolute paths, we must keep one crucial thing in mind: if a build fails before completing, the temporary directory will remain dirty and block subsequent clone attempts. Always make sure to clean up before cloning.
Here is an example of a robust, self-healing workflow:
name: Deploy Website
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Clean up and Clone repository
run: |
rm -rf /tmp/site-sources
git clone http://127.0.0.1:3205/my-user/my-site.git /tmp/site-sources
cd /tmp/site-sources
- name: Compile with Zola
run: |
cd /tmp/site-sources
zola build
- name: Deploy to production
run: |
# Clear webroot and copy new files generated by Zola
rm -rf /var/www/my-static-site.it/*
cp -r /tmp/site-sources/public/* /var/www/my-static-site.it/
# Final cleanup
rm -rf /tmp/site-sourcesNow we just need to push to main or trigger the workflow manually from the web interface to see our site build and deploy at warp speed and in total security.
Discussion and support channel. To discuss configurations or other topics, join us on fluxer.dreadful.work.

