jelius/infra

Forgejo Git Server

Architecture, configuration, and migration guide for my localized Forgejo instance.

View on Git

My Git server is powered by Forgejo (a community-run Gitea fork) and backed by a local Postgres database.

The entire stack—the app, the SSH daemon it front-ends, and the underlying storage mount—runs entirely under a single, unprivileged local user: git. Absolutely nothing is owned by root, no processes run as root, and no part of the request path ever escalates privileges. Everything lives inside /home/git. If this user is ever somehow compromised, the blast radius is trapped strictly inside that home directory. It is a paranoid, highly compartmentalized setup, which is exactly how I like it.

To be completely clear: this single-user containment model is not a replacement for proper containerization (like Docker) or virtualization. I am doing this strictly because of my VPS's resource limitations. If I had the RAM and CPU overhead to spare, I would containerize this, but right now, the host needs every megabyte it can get.

Setup & Migration Guide

Because everything is tightly contained within the git user's home directory and a Postgres database, setting this up from scratch—or migrating it to a new host—is highly predictable.

When I recently migrated this instance to a new server, I relied heavily on AWS EBS snapshots rather than rsyncing over the network (you can use rsync if you want to). Here is the exact, end-to-end process for setting up the environment and migrating the state.

1. Foundation: System Prerequisites

Before touching Forgejo, the underlying infrastructure needs to be wired up. This means setting up Caddy (for the reverse proxy), Anubis (for the WAF), and PostgreSQL 18.

Installing PostgreSQL 18 (Debian):

sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt update
sudo apt install postgresql-18 postgresql-contrib-18 -y

Moving Postgres to a Dedicated Volume: I run the database on a dedicated EBS volume for better IO isolation. Once the block storage is attached to the instance:

# Identify the drive (e.g., /dev/nvme2n1)
lsblk -f

# Format and create the mount point
sudo mkfs -t ext4 /dev/nvme2n1
sudo mkdir -p /mnt/postgres

# Add the UUID to /etc/fstab, then mount it
sudo nvim /etc/fstab

For reference, the /etc/fstab entry looks like this:

UUID=<uuid-goes-here> /mnt/postgres ext4 defaults,nofail 0 2
sudo systemctl daemon-reload
sudo mount -a

# Stop Postgres and move the default data directory
sudo systemctl stop postgresql
sudo apt install rsync
sudo rsync -avx /var/lib/postgresql/18/main/ /mnt/postgres/

# Update postgresql.conf to point to the new data_directory
sudo nvim /etc/postgresql/18/main/postgresql.conf

For reference, update this line in /etc/postgresql/18/main/postgresql.conf:

data_directory = '/mnt/postgres'
# data_directory = '/var/lib/postgresql/18/main'
# Start it back up and verify
sudo systemctl start postgresql
sudo -u postgres psql -c "SHOW data_directory;"

2. The Proxy & WAF Tier

Installing Caddy:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy -y

Caddyfile configuration (/etc/caddy/Caddyfile):

git.jelius.dev {
        route /assets/* {
                reverse_proxy 127.0.0.1:3500
        }

        reverse_proxy 127.0.0.1:3501 {
                header_up X-Real-IP {remote_host}
        }
}

Installing Anubis (WAF):

# Install the custom deb package
wget https://github.com/TecharoHQ/anubis/releases/download/v1.27.0/anubis_1.27.0_arm64.deb
sudo apt install ./anubis_1.27.0_arm64.deb
rm ./anubis_1.27.0_arm64.deb

# Set up the policies
cd /etc/anubis/
wget https://raw.githubusercontent.com/TecharoHQ/anubis/refs/heads/main/data/botPolicies.yaml
sudo nvim forgejo.env

For reference, /etc/anubis/forgejo.env should look like this:

BIND=127.0.0.1:3501
TARGET=[http://127.0.0.1:3500](http://127.0.0.1:3500)
DIFFICULTY=4
SERVE_ROBOTS_TXT=true
COOKIE_DOMAIN=git.jelius.dev
METRICS_BIND=127.0.0.1:9091
POLICY_FNAME=/etc/anubis/default.botPolicies.yaml
# Enable the Forgejo-specific Anubis instance
sudo systemctl enable --now anubis@forgejo
sudo systemctl status anubis@forgejo

3. The git User & Environment

With the system dependencies running, we create the sandbox where Forgejo will actually live.

# Create the constrained user and grant Postgres access
sudo adduser git
sudo usermod -aG postgres git

# Enable Systemd Lingering (CRITICAL)
# Without this, user-level systemd services will terminate the moment the `git` user logs out.
sudo loginctl enable-linger git

(Note: If you are setting up from scratch without migrating an EBS volume, you would build the skeleton directories here by switching to the git user and running mkdir -p ~/.local/bin ~/.local/var/lib/forgejo ~/.local/var/log ~/.config/forgejo ~/.config/systemd/user ~/repositories. If migrating via EBS, skip this, as the mount handles it.)

4. The Migration (EBS Snapshot Method)

Because I offload LFS to S3, the local disk footprint is minimal. We just need to sync the relational database state and the bare repositories.

On the Old Server:

  1. Stop the world: Halt the Forgejo service so no new commits or DB writes occur.
  2. Dump the Database: Create a custom-format dump.
sudo -i -u postgres
# Note: Adjust the port (-p) if your old server uses a custom Postgres port
pg_dump -p 6969 -Fc git > /tmp/git_db.dump
exit
sudo mv /tmp/git_db.dump ~/git_db.dump
  1. Snapshot the Volume: Go into the AWS Console, take an EBS snapshot of the disk containing /home/git, and create a new volume from that snapshot in your target region.

On the New Server:

  1. Mount the App State: Attach the new EBS volume to the EC2 instance, then wire it up to /home/git.
# Clear out the default home directory created by `adduser`
sudo rm -rf /home/git/*

# Identify the attached EBS volume
lsblk -f

# Add the drive UUID to /etc/fstab to mount at /home/git
sudo nvim /etc/fstab

For reference, the /etc/fstab entry looks like this:

UUID=<uuid-goes-here>  /home/git  ext4  defaults,nofail  0  2
sudo systemctl daemon-reload
sudo mount -a

# Verify
sudo chown -R git:git /home/git
ls -la /home/git
  1. Restore the Database: Transfer git_db.dump to the new server using scp or other methods and restore it into Postgres.
# Ensure postgres can read the file
chmod 777 ./git_db.dump 

# Run the restore
sudo -u postgres pg_restore -d git < ./git_db.dump

# Clean up
rm ./git_db.dump
  1. Spin it up: Drop your app.ini and .service file into place if they changed, reload the Caddy config (caddy reload --config /etc/caddy/Caddyfile), and start the Forgejo user service. It will boot exactly where it left off.

Binary Management & Execution

The Forgejo binary manages both the web server and the SSH routing. Upgrades and rollbacks are handled via a dead-simple symlink strategy:

  1. I download the versioned file (e.g., forgejo-11.0.7) to ~/.local/bin/.
  2. I symlink ~/.local/bin/forgejo to the current version.
  3. The systemd service always points to the stable symlink.

Upgrading means dropping the new binary, flipping the symlink, and restarting. Rollbacks are instant. No half-written binaries, and no redeploying containers.

Configuration (app.ini)

Here are the critical architectural decisions codified in my config:

  • Database Integration: Connects to Postgres via a local Unix socket utilizing peer authentication.
  • Object Storage Offloading: LFS and attachments are entirely offloaded to AWS S3 (ap-south-1) via the MinIO driver, using SERVE_DIRECT = true to prevent the server from wasting bandwidth proxying the downloads.
  • Registration: Public registration is locked down (DISABLE_REGISTRATION = true).

Reference: /home/git/.config/forgejo/app.ini

APP_NAME = Git
APP_SLOGAN = Get in the server room, Shinji, we're writing in assembly.
RUN_USER = git
WORK_PATH = /home/git/.local/bin
RUN_MODE = prod

[actions]
ENABLED = true

[server]
LOCAL_ROOT_URL = http://localhost:3500/
SSH_DOMAIN = git.jelius.dev
SSH_PORT = 22
DOMAIN = git.jelius.dev
HTTP_PORT = 3500
ROOT_URL = [https://git.jelius.dev/](https://git.jelius.dev/)
APP_DATA_PATH = /home/git/.local/var/lib/forgejo
DISABLE_SSH = false
LFS_START_SERVER = true
LFS_JWT_SECRET = [REDACTED]
OFFLINE_MODE = true

[database]
DB_TYPE  = postgres
HOST     = /var/run/postgresql
NAME     = git
USER     = git
PASSWD   =
SCHEMA   = git
SSL_MODE = disable
LOG_SQL  = false

[repository]
ROOT = /home/git/repositories
USE_COMPAT_SSH_URI = false

[storage]
STORAGE_TYPE = minio
SERVE_DIRECT = true
MINIO_USE_SSL = true
MINIO_ENDPOINT = s3.ap-south-1.amazonaws.com
MINIO_ACCESS_KEY_ID = [REDACTED]
MINIO_SECRET_ACCESS_KEY = [REDACTED]
MINIO_BUCKET = [REDACTED] ; Redacted because of potential Denial-of-wallet attack
MINIO_LOCATION = ap-south-1

[lfs]
MINIO_BASE_PATH = lfs/

[attachment]
MINIO_BASE_PATH = attachments/

[mailer]
ENABLED = true
SMTP_ADDR = email-smtp.ap-northeast-1.amazonaws.com
SMTP_PORT = 587
FROM = Sys Sentinel <noreply@jelius.dev>
USER = [REDACTED]
PASSWD = [REDACTED]

[service]
REGISTER_EMAIL_CONFIRM = true
ENABLE_NOTIFY_MAIL = true
DISABLE_REGISTRATION = true
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
REQUIRE_SIGNIN_VIEW = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost

[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true

[cron.update_checker]
ENABLED = true

[session]
PROVIDER = file

[log]
MODE = console, file
LEVEL = info
ROOT_PATH = /home/git/.local/var/log

[repository.pull-request]
DEFAULT_MERGE_STYLE = merge

[repository.signing]
DEFAULT_TRUST_MODEL = committer

[security]
INSTALL_LOCK = true
INTERNAL_TOKEN = [REDACTED]
PASSWORD_HASH_ALGO = [REDACTED]

[oauth2]
JWT_SECRET = [REDACTED]

The Systemd User Service

The service runs entirely in the git user's space.

Reference: /home/git/.config/systemd/user/forgejo.service

[Unit]
Description=Forgejo
# `postgresql.service` is a root service not running specifically on this user
# This config may not be able to reference it successfully.
After=network.target postgresql.service

[Service]
ExecStart=/home/git/.local/bin/forgejo web --config /home/git/.config/forgejo/app.ini
Restart=on-failure
RestartSec=5s
Environment=HOME=/home/git
Environment=USER=git

[Install]
WantedBy=default.target

Because this is a user-level unit, interacting with it as an administrator isn't as simple as running sudo systemctl status forgejo. If you are logged in as your admin user and need to check on or restart the service, you have to explicitly pass the D-Bus session variables for the git user environment:

sudo -u git XDG_RUNTIME_DIR=/run/user/$(id -u git) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u git)/bus systemctl --user status forgejo.service

Amazon Linux 2023 users can also use the following command (it doesn't work in Debian and I have no idea why):

sudo systemctl --user -M git@ status forgejo

Note: As noted in the unit file comments, because postgresql.service is a system-level service, user-level systemd targets cannot strictly depend on it. Using After=network.target postgresql.service is essentially a best-effort hint in user-space.

Directory Structure

Since offloading LFS to S3, the local directory footprint is very streamlined:

  • /home/git/.local/var/lib/forgejo (App data / SQLite sessions)
  • /home/git/repositories (The bare Git repos)
  • /home/git/.local/var/log (Application logs)

SSH Authorization Handling

By default, I let Forgejo manage its own SSH access layer.

Because SSH_CREATE_AUTHORIZED_KEYS_FILE defaults to true, Forgejo automatically maintains the ~/.ssh/authorized_keys file for the git user. When I add a new public key via the web UI, Forgejo appends it to this file with a prepended command directive. When I push or pull over SSH on port 22, the system's sshd reads that file, verifies the key, and executes the Forgejo binary to handle the Git wire protocol.


Forgejo Actions

With [actions] ENABLED = true declared in my app.ini, Forgejo acts as the control plane for my CI/CD pipelines. Because the VPS hosting this server is strictly compute-constrained, I rely on external runners to execute the actual workloads. Running heavy CI jobs, package installations, or builds locally would instantly starve the host of its limited RAM and CPU.

Here is the exact setup for provisioning an external runner and wiring it back to the Forgejo instance.

1. Generate the Registration Token

Before touching the runner machine, the Forgejo instance needs to authorize it.

  1. Log into Forgejo as an administrator.
  2. Navigate to Site Administration > Actions > Runners.
  3. Click Create new Runner and copy the generated registration token.

2. Runner Host Prerequisites

On the external machine that will handle the CI/CD workloads, Docker must be installed. The runner relies on Docker to spin up isolated containers (like ubuntu-latest) to execute the workflow steps safely.

# Install Docker (Arch Linux)
yay -S docker
sudo systemctl enable --now docker
sudo usermod -aG docker $USER

3. Install and Configure forgejo-runner

Download the official runner binary, make it executable, and provision a dedicated sandbox environment for it.

# Download and install the binary (check Forgejo releases for the latest version)
sudo wget -O /usr/local/bin/forgejo-runner https://code.forgejo.org/forgejo/runner/releases/download/v13.1.0/forgejo-runner-13.1.0-linux-amd64
sudo chmod +x /usr/local/bin/forgejo-runner

# Create a dedicated, unprivileged user for the runner and grant Docker access
sudo useradd -r -m -d /var/lib/forgejo-runner -s /bin/bash forgejo-runner
sudo usermod -aG docker forgejo-runner
sudo loginctl enable-linger forgejo-runner

# Generate the default configuration file
sudo mkdir -p /etc/forgejo-runner
sudo forgejo-runner generate-config > /tmp/config.yaml
sudo mv /tmp/config.yaml /etc/forgejo-runner/config.yaml
sudo chown -R forgejo-runner:forgejo-runner /etc/forgejo-runner

# Securely store the registration token
sudo -u forgejo-runner bash -c 'echo -n "<YOUR_REGISTRATION_TOKEN>" > /var/lib/forgejo-runner/.runner-token'

4. The Systemd Runner Service

To ensure the runner survives reboots and stays constantly connected to Forgejo, daemonize it using systemd.

sudo nvim /etc/systemd/system/forgejo-runner.service

For reference, the service file looks like this:

[Unit]
Description=Forgejo Actions Runner
After=docker.service

[Service]
User=forgejo-runner
Group=forgejo-runner
WorkingDirectory=/var/lib/forgejo-runner
ExecStart=/usr/local/bin/forgejo-runner daemon \
  --config /etc/forgejo-runner/config.yaml \
  --url https://git.jelius.dev \
  --uuid <YOUR_UUID> \
  --token-url file:///var/lib/forgejo-runner/.runner-token \
  --label ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest \
  --label ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04
Restart=always
RestartSec=5s

[Install]
WantedBy=multi-user.target

(Note: The --label flags in the service file above are critical—they map the workflow's runs-on: ubuntu-latest requirement to the actual Docker images the runner will pull and use for the job containers).

# Start and enable the service
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner.service
sudo systemctl status forgejo-runner.service

Once the service starts, the runner will automatically register using the token file and appear as "Online" in the Forgejo Admin panel, ready to pick up jobs.

Custom Pages Implementation (Cloudflare Pages Pipeline)

Rather than hosting static sites directly on the VPS and wasting precious compute, memory, and bandwidth, I offload static hosting entirely to Cloudflare's edge network. To standardize deployments across all my static web projects, I maintain a custom composite action (jelius-sama/pages).

Architectural Note: I initially built this deployment pipeline targeting Cloudflare R2 (Object Storage). That required maintaining complex edge Transform Rules to forcefully append .html to clean URLs and route root requests to index.html. Because R2 is fundamentally a dumb file system, it also lacked native 404 fallbacks. I rewrote the pipeline to use Cloudflare Pages, which provides zero-config routing, native custom error pages, and the exact same free egress, eliminating the need for API hacks.

Here is how the automated deployment pipeline operates:

1. The Build Phase The downstream repository's workflow compiles the static site and exports the raw assets to a local ./out (configurable) directory.

2. Dynamic Provisioning & Deployment The jelius-sama/pages action takes over. Instead of hardcoding project names, the action intercepts the runtime context (${{ github.repository }}), strips the owner prefix, and automatically derives the Cloudflare project name (e.g., forgejo-pages-repo-name). It then uses the Cloudflare wrangler CLI to provision the project on the fly if it doesn't exist, and pushes the ./out (configurable) directory directly to the edge.

3. Idempotent DNS Automation Wrangler cannot natively attach custom domains in non-interactive CI environments, so the action directly queries the Cloudflare REST API to bind the requested custom domain (e.g., sub.jelius.dev) to the Pages project.

  • It parses the API JSON response to safely handle "already exists" errors on subsequent runs without failing the pipeline.
  • It automatically looks up the Zone ID for the root domain, verifies if a CNAME record exists, and provisions a proxied CNAME targeting the <project-name>.pages.dev alias to handle SSL and DNS resolution instantly.

Authentication The entire pipeline is stateless and authenticates using just two Forgejo Secrets passed from the repository: CF_ACCOUNT_ID and a scoped CF_MANAGEMENT_TOKEN (restricted strictly to Pages Edit and DNS Edit permissions).

On this page