jani@raatti:~ $ cat ~/blog/self-hosted-git-with-forgejo-on-rhel.md
---
title: "Self-hosted Git with Forgejo on RHEL"
date: 2026-04-11
author: jani
categories: [DevOps, Linux, Server]
tags: [devops, forgejo, git, gitea]
reading_time: 18 min
cover: "forge-and-code.png"
---
Forging code in git
Blacksmith forging code - self-hosted Git with Forgejo

I keep my own hardware, my own backups, and my own rules. GitHub is fine for open source — but for personal projects, config experiments, and anything I might eventually pipe into my own Nextcloud instance, I wanted the whole chain on my own server. No Microsoft in the middle, no AI training on my commits, no dependency on someone else’s uptime, my infra is up – my stuff is up.

This is a straight install guide for Forgejo on RHEL (10) — single binary, systemd service, MariaDB backend, Apache reverse proxy, and direct SSH access on port 2222. If you’re running a similar LAMP stack, this drops in cleanly.


What is Forgejo

Forgejo is a community-driven fork of Gitea — a lightweight, self-hosted Git forge. Web UI, issue tracker, pull requests, CI webhooks, the works. Single Go binary, no runtime dependencies, runs happily on modest hardware. It’s what Gitea should have stayed being before the commercial drift.

Prerequisites
This guide assumes RHEL 10 (or AlmaLinux/Rocky equivalent), Apache already running, MariaDB already running, and a domain with DNS pointing at your server. Adjust paths for other distros as needed.

Step 1: Create the git user

Forgejo runs as a dedicated system user. SSH git operations will also authenticate through this user.

useradd --system --shell /bin/bash --comment "Forgejo" --create-home --home-dir /home/git git

Create the directory structure Forgejo expects:

mkdir -p /var/lib/forgejo/{custom,data,log,repos}
chown -R git:git /var/lib/forgejo
chmod -R 750 /var/lib/forgejo

mkdir /etc/forgejo
chown root:git /etc/forgejo
chmod 770 /etc/forgejo

Step 2: Download the Forgejo binary

Grab the latest release from forgejo.org/releases. Check the current stable version before running this — Forgejo has short support windows (typically six to eight weeks per release).

FORGEJO_VERSION="14.0.3"
wget -O /usr/local/bin/forgejo \
  "https://codeberg.org/forgejo/forgejo/releases/download/v${FORGEJO_VERSION}/forgejo-${FORGEJO_VERSION}-linux-amd64"

chmod +x /usr/local/bin/forgejo

Verify the binary runs:

forgejo --version

Step 3: Create the MariaDB database

mysql -u root -p
CREATE DATABASE forgejo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'forgejo'@'127.0.0.1' IDENTIFIED BY 'STRONG_PASSWORD_HERE';
GRANT ALL PRIVILEGES ON forgejo.* TO 'forgejo'@'127.0.0.1';
FLUSH PRIVILEGES;
EXIT;
Why 127.0.0.1 and not localhost?
In MariaDB, localhost means “connect via Unix socket” while 127.0.0.1 means “connect via TCP”. Forgejo connects via TCP to 127.0.0.1:3306, so the grant must match. If you create a user with @localhost, Forgejo won’t be able to authenticate.
⚠️ charset matters
utf8mb4 is required. Plain utf8 in MariaDB is a broken 3-byte subset that chokes on emoji and some Unicode. Don’t skip the collation line.

Step 4: Create the app.ini configuration

Forgejo reads its config from /etc/forgejo/app.ini. Create it as root, then we’ll lock it down after first run.

cat > /etc/forgejo/app.ini << 'EOF'
APP_NAME = git.raatti.net
RUN_USER = git
RUN_MODE = prod
WORK_PATH = /var/lib/forgejo

[server]
DOMAIN           = git.raatti.net
HTTP_ADDR        = 127.0.0.1
HTTP_PORT        = 3001
ROOT_URL         = https://git.raatti.net/
DISABLE_SSH      = false
SSH_DOMAIN       = git.raatti.net
SSH_PORT         = 2222
START_SSH_SERVER = true
OFFLINE_MODE     = true

[database]
DB_TYPE  = mysql
HOST     = 127.0.0.1:3306
NAME     = forgejo
USER     = forgejo
PASSWD   = STRONG_PASSWORD_HERE
CHARSET  = utf8mb4

[repository]
ROOT = /var/lib/forgejo/repos

[log]
MODE      = file
LEVEL     = info
ROOT_PATH = /var/lib/forgejo/log

[security]
INSTALL_LOCK        = false
SECRET_KEY          =
INTERNAL_TOKEN      =

[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW  = true

[mailer]
ENABLED = false
EOF

chown root:git /etc/forgejo/app.ini
chmod 640 /etc/forgejo/app.ini
Note on INSTALL_LOCK
INSTALL_LOCK = false here is intentional — it tells Forgejo to show the first-run setup wizard. After you complete the wizard in Step 8, Forgejo automatically sets this to true in app.ini. If it stays false, the installer will appear on every page load.
Note on port 3001
We use port 3001 instead of the default 3000 to avoid conflicts with other services (Grafana, for example, defaults to 3000). Pick any unused high port.
Note on registration
DISABLE_REGISTRATION = true and REQUIRE_SIGNIN_VIEW = true lock the instance down to invited users only. You’ll create your admin account during first-run setup, then this takes effect. Personal forge, not a public service.

Step 5: Create the systemd service

cat > /etc/systemd/system/forgejo.service << 'EOF'
[Unit]
Description=Forgejo - Beyond coding. We Forge.
After=network.target mariadb.service

[Service]
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/forgejo
ExecStart=/usr/local/bin/forgejo web --config /etc/forgejo/app.ini
Restart=on-failure
RestartSec=5s
EnvironmentFile=-/etc/forgejo/forgejo.env

PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now forgejo

Confirm it’s running:

systemctl status forgejo
ss -tlnp | grep 3001

Step 6: Apache reverse proxy and Cloudflare Tunnel

We need two things: an Apache vhost to reverse proxy to Forgejo, and a Cloudflare Tunnel route so the outside world can reach it without opening firewall ports.

SELinux configuration

If you have SELinux enforcing (you should), Apache needs permission to make network connections to the Forgejo backend:

# Allow Apache to connect to network services
setsebool -P httpd_can_network_connect 1

# Label port 3001 as an HTTP port
semanage port -a -t http_port_t -p tcp 3001

If port 3001 is already labeled for something else, use -m instead of -a to modify it.

Apache vhost

Add a new vhost for git.raatti.net. Apache listens on 80 — Cloudflare Tunnel handles TLS termination at the edge.

cat > /etc/httpd/conf.d/git.raatti.net.conf << 'EOF'
<VirtualHost *:80>
    ServerName git.raatti.net

    ProxyPreserveHost On
    ProxyRequests Off

    ProxyPass        / http://127.0.0.1:3001/
    ProxyPassReverse / http://127.0.0.1:3001/

    RequestHeader set X-Forwarded-Proto "https"
    RequestHeader set X-Real-IP %{REMOTE_ADDR}s

    ErrorLog  /var/log/httpd/forgejo_error.log
    CustomLog /var/log/httpd/forgejo_access.log combined
</VirtualHost>
EOF

apachectl configtest && systemctl reload httpd

Cloudflare Tunnel

If you’re already running Cloudflare Tunnel (see my Cloudflare Tunnel guide), you need to do two things: create the DNS route and add the ingress rule. Both are required — if you skip either one, you’ll get a 404.

First, create the DNS route so Cloudflare knows to send traffic for this hostname to your tunnel:

cloudflared tunnel route dns your-tunnel-name git.raatti.net

Then open /etc/cloudflared/config.yml in your editor and add a new ingress rule for the git hostname. Insert it before the catch-all http_status:404 rule at the end. For example, if your existing config looks like this:

tunnel: your-tunnel-id
credentials-file: /etc/cloudflared/credentials.json

ingress:
  - hostname: raatti.net
    service: http://localhost:80
  - hostname: www.raatti.net
    service: http://localhost:80
  - service: http_status:404

Add the git.raatti.net line so it becomes:

tunnel: your-tunnel-id
credentials-file: /etc/cloudflared/credentials.json

ingress:
  - hostname: raatti.net
    service: http://localhost:80
  - hostname: www.raatti.net
    service: http://localhost:80
  - hostname: git.raatti.net
    service: http://localhost:80
  - service: http_status:404

The DNS route tells Cloudflare’s edge to send traffic to your tunnel. The ingress rule tells cloudflared where to forward it locally. Without the ingress rule, cloudflared receives the request but doesn’t match any hostname and returns 404.

Restart cloudflared to pick up the config change:

systemctl restart cloudflared

The tunnel routes traffic through Cloudflare’s network to your server via an outbound connection — no inbound firewall rules needed for HTTP/HTTPS. Your origin stays invisible.

Why localhost:80 and not localhost:3001?
The tunnel points at Apache (port 80), which then proxies to Forgejo (port 3001). This keeps all HTTP routing in one place and lets Apache handle headers, logging, and any future vhost complexity.

Step 7: Restrict SSH to the Tailscale interface

Forgejo’s built-in SSH server listens on port 2222. Rather than opening this to the public internet, we bind it exclusively to the Tailscale network interface — so only trusted devices on your tailnet can reach it. No exposure, no port scanners, no brute force attempts.

First, find your Tailscale interface name and IP:

ip addr show tailscale0
# or: tailscale ip -4

Update app.ini to bind the SSH server to the Tailscale IP only:

[server]
; ... other settings ...
SSH_LISTEN_HOST  = 100.x.x.x   # your Tailscale IP
SSH_PORT         = 2222

Now open port 2222 in firewalld, but scoped to the Tailscale interface only — not the public zone:

# Add tailscale0 to the trusted or internal zone (not public)
firewall-cmd --permanent --zone=trusted --add-interface=tailscale0
firewall-cmd --permanent --zone=trusted --add-port=2222/tcp
firewall-cmd --reload

Confirm port 2222 is not reachable from the public zone:

firewall-cmd --zone=public --list-ports   # 2222 should NOT appear here
firewall-cmd --zone=trusted --list-ports  # 2222 should appear here

If you have SELinux enforcing, label the port:

semanage port -a -t ssh_port_t -p tcp 2222
Why Tailscale and not a firewall allowlist?
Tailscale uses WireGuard under the hood and authenticates devices with your identity provider — only enrolled devices can join the network at all. There’s no open port on the public internet for anyone to probe. Compared to an IP allowlist (which breaks when your ISP changes your address), it’s both more secure and more convenient.

Step 8: First-run setup

Navigate to https://git.raatti.net. The installer will appear. Most fields are pre-filled from your app.ini — verify the database credentials and set your admin account.

Create admin account now
Since we set DISABLE_REGISTRATION = true, you won’t be able to create accounts after the initial setup. Scroll down to the “Administrator Account Settings” section and create your admin user before clicking “Install Forgejo”.
⚠️ Be patient on first install
The first-run setup creates all database tables and initial data. This can take several minutes — you may see a 502 gateway timeout on your first attempt. Wait 5–6 minutes and refresh. Don’t click “Install” multiple times.

After completing the wizard, Forgejo writes the generated SECRET_KEY and INTERNAL_TOKEN into app.ini, and sets INSTALL_LOCK = true automatically. Lock the file down once that’s done:

chmod 640 /etc/forgejo/app.ini

Step 9: SSH key setup for git access

On your client machine, make sure it’s enrolled in Tailscale, then add the SSH config so git uses port 2222 via the Tailscale IP transparently:

# ~/.ssh/config
Host git.raatti.net
    User git
    Port 2222
    HostName 100.x.x.x     # your server's Tailscale IP
    IdentityFile ~/.ssh/id_ed25519

In the Forgejo web UI, add your public key under Settings → SSH / GPG Keys. Then test:

ssh -T [email protected]

You should see: Hi username! You’ve successfully authenticated…

Clone URL pattern for your repos will be:

git clone [email protected]:username/repo.git
# or via HTTPS (available publicly through Cloudflare Tunnel):
git clone https://git.raatti.net/username/repo.git
SSH vs HTTPS access model
SSH (push/pull over port 2222) is Tailscale-only — trusted devices only. HTTPS read access goes through Cloudflare Tunnel and is gated by REQUIRE_SIGNIN_VIEW = true, so unauthenticated visitors see nothing. You get a private forge that’s invisible to the internet at the transport layer.

Migrating from GitHub

Forgejo has a built-in migration tool under + → New Migration → GitHub. It pulls the repo, issues, labels, milestones, and wiki — all you need is a GitHub personal access token. For private repos you want to fully exit, it’s the cleanest path.

For repos you want to keep public, Forgejo supports push mirrors — your Forgejo instance is the source of truth, and it pushes automatically to GitHub (or GitLab, Codeberg, wherever). You stay in control of the canonical copy while maintaining a public face wherever your audience is.


Keeping Forgejo updated automatically

Forgejo stable releases have short support windows — typically around six to eight weeks. Staying current isn’t optional; an unsupported release won’t get security patches. Rather than tracking this manually, a weekly systemd timer handles it cleanly.

The approach: check the Codeberg API for the latest release, compare against the installed version, download and verify if newer, swap the binary, restart the service. Everything logged. No action taken if already current.

Create the update script:

cat > /usr/local/sbin/forgejo-update.sh << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail

LOG=/var/log/forgejo-update.log
BIN=/usr/local/bin/forgejo
ARCH=linux-amd64

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }

LATEST=$(curl -fsSL \
  "https://codeberg.org/api/v1/repos/forgejo/forgejo/releases?limit=10&pre-release=false" \
  | grep -oP '"tag_name":\s*"v\K[0-9]+\.[0-9]+\.[0-9]+' \
  | head -1)

if [[ -z "$LATEST" ]]; then
  log "ERROR: Could not fetch latest version from Codeberg API"
  exit 1
fi

CURRENT=$("$BIN" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)

if [[ -z "$CURRENT" ]]; then
  log "ERROR: Could not determine installed Forgejo version"
  exit 1
fi

log "Installed: v${CURRENT}  |  Latest: v${LATEST}"

if [[ "$CURRENT" == "$LATEST" ]]; then
  log "Already up to date, nothing to do."
  exit 0
fi

log "Updating Forgejo v${CURRENT} -> v${LATEST}"

DOWNLOAD_URL="https://codeberg.org/forgejo/forgejo/releases/download/v${LATEST}/forgejo-${LATEST}-${ARCH}"
SHA_URL="${DOWNLOAD_URL}.sha256"
TMPBIN=$(mktemp /tmp/forgejo.XXXXXX)

log "Downloading binary..."
curl -fsSL -o "$TMPBIN" "$DOWNLOAD_URL"
curl -fsSL "$SHA_URL" | awk '{print $1}' > /tmp/forgejo.sha256.expected
echo "$(sha256sum $TMPBIN | awk '{print $1}')" > /tmp/forgejo.sha256.actual

if ! diff -q /tmp/forgejo.sha256.expected /tmp/forgejo.sha256.actual >/dev/null 2>&1; then
  log "ERROR: SHA256 checksum mismatch, aborting update."
  rm -f "$TMPBIN" /tmp/forgejo.sha256.*
  exit 1
fi

log "Checksum OK. Swapping binary and restarting service..."
chmod +x "$TMPBIN"
mv -f "$TMPBIN" "$BIN"
rm -f /tmp/forgejo.sha256.*

systemctl restart forgejo
sleep 3

if systemctl is-active --quiet forgejo; then
  log "Forgejo restarted successfully on v${LATEST}."
else
  log "ERROR: Forgejo failed to restart after update. Check: journalctl -u forgejo"
  exit 1
fi
SCRIPT

chmod +x /usr/local/sbin/forgejo-update.sh

Create the systemd service and timer:

cat > /etc/systemd/system/forgejo-update.service << 'EOF'
[Unit]
Description=Forgejo automatic update
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/forgejo-update.sh
EOF

cat > /etc/systemd/system/forgejo-update.timer << 'EOF'
[Unit]
Description=Weekly Forgejo update check

[Timer]
OnCalendar=Mon *-*-* 03:00:00
RandomizedDelaySec=1800
Persistent=true

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now forgejo-update.timer

Confirm the timer is scheduled:

systemctl list-timers forgejo-update.timer

Run it manually for the first time to confirm everything works:

systemctl start forgejo-update.service
tail -f /var/log/forgejo-update.log
What this does and doesn’t do
The script updates to the latest stable release only — pre-releases are excluded. It verifies the SHA256 checksum before swapping the binary. If the checksum fails or the service doesn’t come back up, it logs the error and exits. Your app.ini and data directories are untouched — only the binary is replaced.
⚠️ Major version upgrades
Forgejo occasionally requires a database migration on major version bumps (e.g. v14 → v15). The update script handles patch and minor releases safely, but review the release notes before a major version jump. If you’d rather approve major upgrades manually, add a version check: if [[ "${LATEST%%.*}" != "${CURRENT%%.*}" ]]; then log "Major version bump detected, skipping."; exit 0; fi

Backing up to Nextcloud with rclone

The goal: daily git bundles of all repos, synced to Nextcloud over WebDAV. The Nextcloud URL and credentials stay in a protected config file — nothing exposed in scripts or logs. rclone handles retries, resume on interrupted transfers, and bandwidth limiting.

Install rclone

rclone is available in EPEL, or grab the latest static binary directly:

# From EPEL
dnf install rclone

# Or latest from rclone.org
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip
unzip rclone-current-linux-amd64.zip
cp rclone-*-linux-amd64/rclone /usr/local/bin/
chmod +x /usr/local/bin/rclone

Configure the Nextcloud remote

Run the interactive config as root (since the backup script runs as root):

rclone config

Follow the prompts:

n) New remote
name> nextcloud
Storage> webdav
url> https://nxYYYY.your-storageshare.de/remote.php/dav/files/USERNAME/
vendor> nextcloud
user> your-username
password> (enter an app password, not your main password)
bearer_token> (leave blank)
Edit advanced config? n

This creates /root/.config/rclone/rclone.conf. The password is automatically obscured. Lock it down:

chmod 600 /root/.config/rclone/rclone.conf

Test the connection:

rclone lsd nextcloud:

You should see your Nextcloud folders listed.

Finding the correct WebDAV URL
The WebDAV URL varies between Nextcloud providers. To find yours: log into Nextcloud, go to Files, click the Settings gear icon at the bottom left, and look for the WebDAV URL. For Hetzner Storage Share it’s https://nxYYYY.your-storageshare.de/remote.php/dav/files/USERNAME/. The trailing slash matters.
Use an app password
In Nextcloud, go to Settings → Security → Devices & sessions and create an app password specifically for rclone. This way you can revoke it independently without changing your main password, and it bypasses 2FA. Regular password login is often disabled for WebDAV.

Create the backup script

This script creates git bundles from all repositories and syncs them to Nextcloud. Bundles are self-contained — they include the full history and can recreate the repo from scratch.

cat > /usr/local/sbin/forgejo-backup.sh << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail

REPO_ROOT="/var/lib/forgejo/repos"
BACKUP_DIR="/var/lib/forgejo/backups"
REMOTE="nextcloud:Backups/forgejo"
LOG="/var/log/forgejo-backup.log"
RETENTION_DAYS=7
BANDWIDTH="5M"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }

log "=== Starting Forgejo backup ==="
mkdir -p "$BACKUP_DIR"

BUNDLE_COUNT=0
while IFS= read -r -d '' repo; do
    rel_path="${repo#$REPO_ROOT/}"
    rel_path="${rel_path%.git}"
    bundle_name="$(echo "$rel_path" | tr '/' '_')_$(date +%Y%m%d).bundle"
    bundle_path="$BACKUP_DIR/$bundle_name"
    log "Bundling: $rel_path"
    if git -C "$repo" bundle create "$bundle_path" --all 2>/dev/null; then
        ((BUNDLE_COUNT++)) || true
    else
        log "WARNING: Failed to bundle $rel_path (might be empty repo)"
    fi
done < <(find "$REPO_ROOT" -maxdepth 3 -type d -name "*.git" -print0)

log "Created $BUNDLE_COUNT bundles"
find "$BACKUP_DIR" -name "*.bundle" -mtime +"$RETENTION_DAYS" -delete

log "Syncing to Nextcloud..."
if rclone copy "$BACKUP_DIR" "$REMOTE" \
    --bwlimit "$BANDWIDTH" \
    --retries 5 \
    --retries-sleep 30s \
    --log-file "$LOG" \
    --log-level INFO; then
    log "Sync completed successfully"
else
    log "ERROR: Sync failed"
    exit 1
fi

rclone delete "$REMOTE" --min-age "${RETENTION_DAYS}d" --log-file "$LOG" --log-level INFO || true
log "=== Backup complete ==="
SCRIPT

chmod +x /usr/local/sbin/forgejo-backup.sh

Create the systemd timer

Run the backup daily at 4 AM:

cat > /etc/systemd/system/forgejo-backup.service << 'EOF'
[Unit]
Description=Forgejo backup to Nextcloud
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/forgejo-backup.sh
EOF

cat > /etc/systemd/system/forgejo-backup.timer << 'EOF'
[Unit]
Description=Daily Forgejo backup

[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now forgejo-backup.timer

Confirm the timer is scheduled:

systemctl list-timers forgejo-backup.timer

Run it manually to test:

systemctl start forgejo-backup.service
tail -f /var/log/forgejo-backup.log

Restoring from a bundle

If you ever need to restore a repo from a bundle:

# Clone from bundle
git clone repo_name_20260406.bundle restored-repo

# Or restore into existing repo
cd existing-repo
git pull /path/to/repo_name_20260406.bundle
What gets backed up
Git bundles contain the complete repository history — all branches, all tags, all commits. What they don’t contain: issues, pull requests, wiki content, webhooks, or user settings. Those live in the MariaDB database. For a complete disaster recovery solution, also back up /etc/forgejo/app.ini and run mysqldump forgejo on a schedule.
⚠️ Bandwidth limiting
The script defaults to 5 MB/s upload limit. Adjust the BANDWIDTH variable as needed. rclone also supports time-based limits like "08:00,1M 23:00,10M" to throttle during business hours.

Summary

Forgejo sits at around 50MB RAM at idle on this server — barely noticeable alongside the rest of the stack. The whole setup took under an hour from binary download to first commit pushed. If you’re already running Apache and MariaDB, there’s almost no additional moving parts.

The access model is deliberately layered. The web UI and HTTPS cloning go through Cloudflare Tunnel — always encrypted, origin hidden, login required. SSH access for push/pull is bound to the Tailscale interface only, invisible to the public internet entirely. Port 2222 simply doesn’t exist from the outside.

For repos that need a public face, Forgejo’s push mirror feature handles that cleanly — your server stays the source of truth, GitHub or Codeberg get a read-only copy. When the next GitHub acquisition scare or terms-of-service change rolls around, you’re already somewhere else. Your code stays on your hardware, under your control, and nobody else can bring it down.

jani@raatti:~ $ git commit # leave a comment