Self-Hosting Forgejo: A Fully Autonomous Code Forge with Actions
In this tutorial, we will walk through the installation of Forgejo, a community-led fork of Gitea, on a single Virtual Machine (VM). The goal is to set up a fully autonomous, GitHub-like service that you completely control.
Why self-host?
Beyond the obvious benefits of data sovereignty and privacy, self-hosting a forge at a smaller organizational scale can offer significant performance boosts. Unlike global platforms like GitHub, which must make complex trade-offs to support millions of users across the globe, your local instance can be tuned for speed. With zero network latency to a remote server and dedicated resources, git operations, web UI responsiveness, and CI/CD pipelines can feel near-instant.
Architecture Overview
We will deploy the entire stack using Docker Compose for simplicity and portability. The setup includes the Forgejo web server, a PostgreSQL database, and a built-in runner for Forgejo Actions (mostly compatible with GitHub Actions).
By default, we will configure Forgejo to use port 22 for SSH, so that your Git URLs remain standard (e.g., git@forge.example.com:user/repo.git). This usually requires re-configuring the VM's host SSH service to use a different port.
graph TD
subgraph "Virtual Machine"
subgraph "Docker Compose"
Traefik[Traefik Ingress] -->|Port 443| Forgejo[Forgejo Server]
Traefik -->|Port 22| Forgejo
Forgejo -->|Port 5432| DB[PostgreSQL]
subgraph "Forgejo Actions"
Runner[Forgejo Runner] -->|API| Forgejo
Runner -->|Docker API| DinD[Docker-in-Docker]
subgraph "CI Jobs"
DinD --- Job1[CI Container 1]
DinD --- Job2[CI Container 2]
end
end
end
Volumes[(Persistent Volumes)]
Forgejo --- Volumes
DB --- Volumes
Runner --- Volumes
DinD --- Volumes
end
User((User)) --> Traefik
Components Breakdown
- Traefik: Acts as the ingress controller, handling HTTPS (via Let's Encrypt) and routing SSH traffic on port 22.
- Forgejo Server: The core application providing the web interface, git hosting, and API.
- PostgreSQL: The database storing users, repositories metadata, and issue tracking data.
- Forgejo Runner: A daemon that polls the server for pending CI/CD jobs and executes them.
- Docker-in-Docker (DinD): Provides a dedicated Docker environment for the runner to spin up job containers safely without exposing the host's Docker socket.
Step 1: Prerequisites
To ensure a smooth installation, your environment should meet the following requirements:
- Virtual Machine: A Linux VM (Ubuntu/Debian recommended) with a public IP address.
- Resources:
- RAM: At least 2 GB. The base setup uses less than 1 GB, but Forgejo Actions runners need extra overhead for build processes (compilation, container startup, etc.).
- Storage: At least 20 GB of SSD storage for Docker images and repository data.
- Domain Name: A (sub)domain (e.g.,
forge.example.com) pointing to your VM's public IP. - Software: Docker and Docker Compose installed.
Step 2: DNS Configuration
Before starting the installation, configure your DNS. This allows time for propagation while you set up the VM. Simply create an A record (for IPv4) and/or an AAAA record (for IPv6) for your chosen domain (e.g., forge.example.com) pointing to your VM's public IP.
Step 3: Environment Setup
We will use a .env file to manage the configuration. It's crucial to fill this file entirely before starting the stack, as some values are required for the initial setup.
Create a directory for the project and the .env file:
mkdir -p ~/forgejo
cd ~/forgejo
Create the ~/forgejo/.env file. Copy the template below and manually fill in the domain and email. You can generate a random database password using a tool of your choice (e.g., openssl rand -hex 16).
# The domain where Forgejo will be accessible (e.g., forge.example.com)
FORGEJO_DOMAIN=
# Email for Let's Encrypt certificate notifications
LETSENCRYPT_EMAIL=
# Database password (choose a strong random one)
DB_PASSWORD=
# Runner registration token (will be filled later in Step 10)
# RUNNER_TOKEN=
Step 4: Prepare the Directory Structure
Forgejo requires specific persistent storage. Create the necessary directories and set the correct permissions:
# Data directories on the host
sudo mkdir -p /var/lib/forgejo
sudo mkdir -p /var/lib/forgejo-db
sudo mkdir -p /var/lib/forgejo-runner
# Set ownership
# 1000:1000 is the default UID/GID for the Forgejo user inside the container
sudo chown -R 1000:1000 /var/lib/forgejo
sudo chown -R 1000:1000 /var/lib/forgejo-runner
# 999:999 is commonly used by the postgres container
sudo chown -R 999:999 /var/lib/forgejo-db
Step 5: Prepare the Host SSH for Git Access (Port 22)
By default, the host's SSH service (used for management) runs on port 22. To allow Git operations (clone/push) to work over the standard port 22, you must move the host's SSH service to another port (e.g., 2223).
This is a recommended step for a better user experience, as it allows users to use git clone git@forge.example.com:user/repo.git without specifying a port.
Note: If you don't use SSH to manage your VM (for example, if you manage it through a console like Incus or a provider's web UI), you can skip the sshd reconfiguration but still follow the next steps to bind Forgejo to port 22.
Warning: Be careful not to lock yourself out! Before restarting sshd, ensure you have another way to access the VM or that you've allowed the new port in your firewall.
- Update your SSH client configuration or use the
-pflag for your next connection:ssh -p 2223 user@your-vm-ip.
Restart the SSH service:
sudo systemctl restart ssh
Find the line Port 22 (or add it if missing) and change it to your new management port:
Port 2223
Edit the SSH configuration file:
sudo nano /etc/ssh/sshd_config
Step 6: Docker Compose Configuration
Create a compose.yml file in ~/forgejo. This file configures the entire stack, including Traefik for automatic HTTPS redirection.
Why these choices?
- Traefik Labels: Instead of separate config files, we use Docker labels. Traefik "watches" the Docker socket and automatically configures routing based on these labels.
- Redirection: We configure Traefik's
webentrypoint to automatically redirect all HTTP traffic towebsecure(HTTPS). - SSH on Port 22: We route SSH traffic through Traefik using a TCP router. We use the standard port
22for Git, assuming you moved the host's management SSH as described in the previous step. - DinD: Using a separate
dindservice for Actions is more secure than mounting/var/run/docker.sockfrom the host, as it isolates build jobs from the host system.
volumes:
dind: # For Docker-in-Docker to persist image and container data
traefik-certs: # For Traefik to store SSL certificates (Let's Encrypt)
services:
# Traefik: Ingress controller for HTTPS (automatic certs) and SSH routing
traefik:
image: traefik:v3
container_name: traefik
command:
- "--providers.docker=true" # Listen to Docker events for routing
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80" # HTTP entrypoint
- "--entrypoints.websecure.address=:443" # HTTPS entrypoint
- "--entrypoints.ssh.address=:22" # SSH entrypoint for Git
# Automatic HTTP to HTTPS redirection at the entrypoint level
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
# Configuration for Let's Encrypt
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--certificatesresolvers.myresolver.acme.email=${LETSENCRYPT_EMAIL}"
- "--certificatesresolvers.myresolver.acme.storage=/letscrypt/acme.json"
ports:
- "80:80"
- "443:443"
- "22:22" # Important: mapping port 22 on the host to Traefik
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" # Required to discover other services
- "traefik-certs:/letscrypt"
restart: always
# Server: The core Forgejo application
server:
image: codeberg.org/forgejo/forgejo:14
container_name: forgejo
environment:
- USER_UID=1000 # Matches the 'forgejo' user inside the container
- USER_GID=1000
- FORGEJO__database__DB_TYPE=postgres
- FORGEJO__database__HOST=db:5432
- FORGEJO__database__NAME=forgejo
- FORGEJO__database__USER=forgejo
- FORGEJO__database__PASSWD=${DB_PASSWORD}
- FORGEJO__server__DOMAIN=${FORGEJO_DOMAIN}
- FORGEJO__server__SSH_DOMAIN=${FORGEJO_DOMAIN}
- FORGEJO__server__ROOT_URL=https://${FORGEJO_DOMAIN}
- FORGEJO__server__SSH_PORT=22 # The port displayed in Git clone URLs
- FORGEJO__server__SSH_LISTEN_PORT=22 # The port the container actually listens on
restart: always
volumes:
- /var/lib/forgejo:/data
- /etc/localtime:/etc/localtime:ro
depends_on:
- db
labels:
- "traefik.enable=true"
# Route HTTPS traffic to Forgejo web UI (port 3000)
- "traefik.http.routers.forgejo.rule=Host(`${FORGEJO_DOMAIN}`)"
- "traefik.http.routers.forgejo.entrypoints=websecure"
- "traefik.http.routers.forgejo.tls.certresolver=myresolver"
- "traefik.http.services.forgejo.loadbalancer.server.port=3000"
# Route SSH traffic for Git operations
- "traefik.tcp.routers.forgejo-ssh.rule=HostSNI(`*`)" # TCP routers don't support Host headers
- "traefik.tcp.routers.forgejo-ssh.entrypoints=ssh"
- "traefik.tcp.services.forgejo-ssh.loadbalancer.server.port=22"
# Database: Stores all user and repository metadata
db:
image: postgres:18
restart: always
environment:
- POSTGRES_USER=forgejo
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=forgejo
volumes:
- /var/lib/forgejo-db:/var/lib/postgresql
# DinD (Docker-in-Docker): Isolated environment for Actions jobs
# This avoids mounting the host's /var/run/docker.sock into build containers.
dind:
image: docker:dind
container_name: forgejo-dind
privileged: true # Required for Docker-in-Docker to function
command: ['dockerd', '-H', 'tcp://0.0.0.0:2375', '--tls=false']
restart: always
volumes:
- dind:/var/lib/docker
# Runner: The daemon that pulls and executes CI/CD jobs
runner:
image: code.forgejo.org/forgejo/runner:12
depends_on:
- server
- dind
container_name: forgejo-runner
user: "0:0" # Needs root to interact with the Docker daemon inside dind
command: forgejo-runner daemon -c /data/config.yaml
volumes:
- /var/lib/forgejo-runner:/data
environment:
- DOCKER_HOST=tcp://dind:2375 # Connects to the dind service for job execution
restart: always
# Runner-Register: A one-off helper service to register the runner
# Run with: docker compose run --rm runner-register
runner-register:
image: code.forgejo.org/forgejo/runner:12
profiles:
- manual
volumes:
- /var/lib/forgejo-runner:/data
environment:
- RUNNER_TOKEN=${RUNNER_TOKEN:-}
command: >
forgejo-runner register
--instance https://${FORGEJO_DOMAIN}
--token ${RUNNER_TOKEN}
--no-interactive
--name my-runner
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
Step 7: Configure the Runner
The runner needs a config.yaml file to interact with the Docker-in-Docker instance.
Create /var/lib/forgejo-runner/config.yaml:
log:
level: info
runner:
file: .runner
capacity: 1
timeout: 3h
fetch_timeout: 5s
fetch_interval: 2s
envs:
DOCKER_HOST: tcp://dind:2375
container:
network: ""
privileged: false
docker_host: tcp://dind:2375
# Deliberate: since the dind is on a different network,
# we make sure to forward it this way
options: "--add-host=dind:host-gateway"
valid_volumes: []
Step 8: Start the Stack
Launch the containers in detached mode:
docker compose up --wait
Step 9: Initial Configuration and Enabling Actions
- Open your browser and navigate to
https://forge.example.com. - Follow the Forgejo installation wizard. Use the database credentials from your
.envfile. - Once Forgejo is set up, you need to enable Actions in the configuration file:
- Edit
/var/lib/forgejo/gitea/conf/app.ini.
- Edit
Restart Forgejo to apply the changes:
docker compose restart server
Add or update the following section:
[actions]
ENABLED = true
Step 10: Registering the Runner
The runner won't work until it is registered with your Forgejo instance. This step creates a unique identity for the runner and defines what kind of jobs it can handle.
- Obtain a Registration Token:
- In the Forgejo web UI, go to Site Administration -> Actions -> Runners.
- Click Create new runner and copy the Registration Token.
Start the Runner Daemon:
docker compose restart runner
Run the Registration:
docker compose run --rm runner-register
Note: This command uses the runner-register service defined in Step 6. It creates a .runner file in /var/lib/forgejo-runner/.
Configure the Token: Add it to your .env file:
echo "RUNNER_TOKEN=your_copied_token" >> .env
For a deeper dive into how labels work, how to manage multiple runners, or how to update them, see the Annex: Runner Registration in Detail.
Step 11: Hello Actions (Testing the Runner)
To verify that everything is correctly wired—the runner, the Docker-in-Docker service, and the network—let's create a test repository and run a simple workflow that builds a Docker image.
- Create a new repository in the Forgejo web UI (e.g.,
test-actions). - Enable Actions for the repo:
- Go to Settings -> Repository.
- Check the Enable Repository Actions box and save.
- Push and Observe:
Commit and push these files to your repository. - Check the results:
- Go to the Actions tab in your repository.
- You should see a new workflow run in progress.
- Click on it to see the logs. If the "Build Docker image" step succeeds and displays "Hello from Forgejo Actions!", your runner and DinD setup are fully functional!
Create the workflow file:
Create a directory named .forgejo/workflows and add a file named test.yml inside it:
name: Hello Actions
on: [push]
jobs:
build-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t test-image .
docker run --rm test-image
Add a Dockerfile:
In the root of your repository, create a file named Dockerfile:
FROM alpine
RUN echo "Hello from Forgejo Actions!"
Annex: Runner Registration in Detail
When we ran the registration command via the runner-register service in our compose.yml, several important things happened:
Labels and Image Selection
The --labels argument (e.g., "ubuntu-latest:docker://catthehacker/ubuntu:act-latest") is the most critical part:
- How they get selected: When you write a workflow with
runs-on: ubuntu-latest, Forgejo looks for a runner that has theubuntu-latestlabel. - The Mapping: The format
label:docker://imagetells the runner: "When you see a job for this label, spin up a container using this specific Docker image." - Why
catthehacker?: We use these images because they are pre-configured foract(Forgejo's action engine). They includenode(required for most actions),git, and common build tools. A rawubuntuimage would fail most actions immediately.
Runner Scope
Runners can be registered at different levels:
- Global (Instance-level): By registering through Site Administration, your runner is available to every repository on the instance.
- Organization/Repository-level: You can also register runners that only work for a specific Org or Project by getting the token from the respective Settings -> Actions -> Runners menu.
Updating and Maintenance
Managing the runner's lifecycle:
- Changing Labels: Labels are sent to the server only during registration. If you want to change labels or add new ones (e.g., adding
debian-latest), you must edit thecompose.yml, re-run the registration command (Step 10.3), and restart the runner (Step 10.4). - Updating the Runner: To update the runner software itself, simply run
docker compose pull runneranddocker compose up -d runner. - Removing a Runner: You can remove a runner from the web UI. If you do, you should also delete the
/var/lib/forgejo-runner/.runnerfile on the VM before trying to re-register.
Tokens and Security
The registration token is only needed once to create the .runner file. Once registered, the runner uses its own generated credentials to communicate. You can rotate the token in the UI without affecting existing runners.
Annex: Security Model and Hardening
This setup is designed for a balance between ease of use and security. Understanding the security layers and their limits is crucial, especially if you plan to host public repositories.
Current Protections
- Docker-in-Docker (DinD) Isolation: By using a separate
dindservice, CI jobs do not have access to the host's Docker socket. A malicious job cannot easily escape the container to control the host machine or other services in the stack. - Unprivileged Containers: The main Forgejo and Database containers run as non-root users (
1000:1000and999:999). This limits the impact if a vulnerability is exploited in the application layer. - Traefik Ingress: Traefik handles all external communication, providing a single, hardened entry point. It manages SSL/TLS certificates automatically, ensuring encrypted traffic.
- Dedicated Runner Volumes: The runner and job data are stored in dedicated volumes, separate from the main Forgejo data.
What it protects you from
- Accidental Host Damage: A buggy CI script won't accidentally delete files on your VM.
- Basic Container Escapes: Standard container breakout techniques that target the Docker socket are mitigated by the DinD layer.
- Service Interference: If the Forgejo web server is compromised, the database and runner are still separate layers with limited access to each other.
Hardening for Public Repositories
If you plan to allow public registration or host public projects with open CI, the current model might not be enough. Consider these improvements:
- Rootless Docker: Configure the
dindservice and the runner to use Rootless Docker. This adds another layer of protection by running the entire Docker daemon without root privileges on the host. - gVisor or Kata Containers: For high-risk environments, replace the standard Docker runtime with a more isolated one like gVisor (from Google) or Kata Containers. These provide stronger kernel-level isolation between the container and the host.
- Resource Limits: Add
deploy.resourceslimits to yourcompose.ymlfor thedindandrunnerservices. This prevents a single job from consuming all the VM's CPU or RAM (Fork-bomb protection). - Network Isolation: Use separate Docker networks for the server and the runner. Ensure the
dindservice cannot reach your local network or the Forgejo internal API unless explicitly required. - Dedicated Build VM: For the ultimate security, run your runners on a completely separate, disposable VM that is wiped after every job.