
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.
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;
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.
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
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.
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.
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.
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
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.
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”.
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 (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
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.
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.
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.
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
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.
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.