Overview
In our earlier guide, Forgejo Installation Guide (MariaDB), we deployed a lightweight, self-hosted Git forge on a RHEL-based Linux host. While hosting repositories independently is a major homelab win, the real power of modern software development lies in automated pipelines—testing code, building static assets, and deploying applications automatically whenever changes are pushed.
Forgejo natively includes Forgejo Actions, an open-source workflow execution engine built on top of act_runner that is syntax-compatible with GitHub Actions.
In this guide, we will:
- Enable Actions inside Forgejo.
- Install and register
forgejo-runnerwith Docker support. - Configure a persistent
systemddaemon to handle jobs 24/7. - Walk through a real-world production case study: the exact automated CI/CD pipeline that builds this portfolio site (
popescualin.me) with Node.js and Astro, then securely uploads the static bundle to production via FTP using Python and encrypted secrets.
Architecture Overview
Here is how the continuous deployment loop operates between Forgejo and production:
[ Developer Workstation ]
│
│ git push alin main
▼
[ Forgejo Instance ] ──────────────┐ (WebHook / Polling)
│ ▼
│ [ forgejo-runner ]
│ │
│ │ Spawns Docker Container
│ ▼
│ ┌─────────────────────────────────┐
│ │ Container Execution: │
│ │ 1. actions/checkout@v4 │
│ │ 2. setup-node & npm ci │
│ │ 3. npm run build (Astro dist/) │
│ │ 4. setup-python & deploy.py │
│ └─────────────────────────────────┘
│ │
│ Secure Secrets Injected │ FTP Upload over TLS
│ (FTP_HOST, FTP_USER, ...) ▼
└─────────────────────────> [ Production Web Host ]
Prerequisites
Before setting up the runner, ensure you have:
- A working Forgejo instance (v1.20+ recommended).
- A server or VM (Rocky Linux, AlmaLinux, Debian, or Ubuntu) with Docker Engine installed and active.
- Sudo or root privileges on the runner machine.
- A target repository to automate.
Step 1: Enable Actions in Forgejo (app.ini)
By default, Actions may be turned off in Forgejo to conserve resources. You must enable the feature in your Forgejo server configuration.
- SSH into your Forgejo server.
- Edit
/etc/forgejo/app.ini(or your custom configuration path, such as/home/git/forgejo/custom/conf/app.ini):
[actions]
ENABLED = true
- Restart the Forgejo service:
sudo systemctl restart forgejo
Once restarted, you will notice an Actions tab appear across repository navigation bars and inside Site Administration.
Step 2: Generate the Runner Registration Token
To register an external worker node, you need a single-use registration token.
You can register a runner at three distinct scopes:
- Global / Instance-wide: Serves all repositories across all organizations. (Navigate to Site Administration → Actions → Runners → Create new Runner).
- Organization-wide: Shared across all repositories in a specific organization.
- Repository-specific: Isolated to a single project. (Navigate to Repo Settings → Actions → Runners → Create new Runner).
Copy the generated Registration Token and note your Instance URL (e.g. http://192.168.1.100:3000 or https://git.example.com).
Step 3: Install Docker on the Runner Host
forgejo-runner uses Docker containers to execute workflow steps in clean, isolated environments.
If Docker is not already installed on your runner node (example for RHEL / Rocky Linux 9):
# Add Docker CE repository
sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
# Install Docker engine
sudo dnf install -y docker-ce docker-ce-cli containerd.io
# Enable and start Docker
sudo systemctl enable --now docker
# Optional: Add your runner user to the docker group
sudo usermod -aG docker $USER
Verify that Docker is operational:
docker ps
Step 4: Download and Configure forgejo-runner
- Download the latest official release binary of
forgejo-runnerfrom Codeberg:
# Determine your architecture (amd64 or arm64)
ARCH="amd64"
VERSION="6.2.2"
sudo curl -fsSL -o /usr/local/bin/forgejo-runner \
"https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/forgejo-runner-${VERSION}-linux-${ARCH}"
sudo chmod +x /usr/local/bin/forgejo-runner
- Create a dedicated directory to store the runner configuration and persistent workspace cache:
sudo mkdir -p /var/lib/forgejo-runner
cd /var/lib/forgejo-runner
- Generate a default configuration file:
forgejo-runner generate-config | sudo tee config.yaml > /dev/null
- Inspect
config.yaml. The critical block isrunner.labels, which maps workflow tags (likeubuntu-latest) to base container images:
runner:
capacity: 2
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- "ubuntu-latest:docker://node:20-bookworm"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
Using docker://node:20-bookworm or docker://catthehacker/ubuntu:act-latest ensures common developer utilities like git, curl, and Node.js are ready inside the container without incurring lengthy installation delays during each build step.
Step 5: Register the Runner
Execute the registration command using your instance URL and the registration token copied in Step 2:
cd /var/lib/forgejo-runner
sudo forgejo-runner register \
--no-interactive \
--instance "http://192.168.1.100:3000" \
--token "<YOUR_REGISTRATION_TOKEN>" \
--name "homelab-docker-runner-01" \
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest,linux:docker://node:20-bookworm"
Upon successful handshake, the runner outputs:
Registering runner, please wait...
Runner registered successfully.
A .runner credentials file containing UUIDs and cryptographic keys is created in the working directory.
Step 6: Create a Persistent systemd Service
To ensure the runner survives server reboots and handles triggers continuously in the background, create a systemd unit.
Create /etc/systemd/system/forgejo-runner.service:
[Unit]
Description=Forgejo Actions Docker Runner
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
WorkingDirectory=/var/lib/forgejo-runner
ExecStart=/usr/local/bin/forgejo-runner daemon --config /var/lib/forgejo-runner/config.yaml
Restart=always
RestartSec=10
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner
Check runner health:
sudo systemctl status forgejo-runner
In your Forgejo Web UI under Admin / Settings → Actions → Runners, the runner status indicator will now illuminate green.
Step 7: Real-World Case Study — Automated Website Deployment
Let’s examine the exact automated deployment pipeline powering this portfolio site.
Whenever code is pushed to the main branch, Forgejo automatically:
- Spawns an isolated container.
- Checks out the repository with full git history.
- Caches and installs npm dependencies.
- Compiles the Astro project into static HTML, CSS, and JS (
dist/). - Prepares a Python 3 environment.
- Connects to Hostinger production FTP, uploads modified assets, and stamps the git commit SHA into
.deploy-revision.
1. The Workflow File: .forgejo/workflows/deploy.yml
Create this file in your project repository under .forgejo/workflows/deploy.yml (or .github/workflows/deploy.yml):
name: Deploy to Production (Hostinger)
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Deploy to FTP
env:
FTP_HOST: ${{ secrets.FTP_HOST }}
FTP_USER: ${{ secrets.FTP_USER }}
FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }}
FTP_REMOTE_DIR: ${{ secrets.FTP_REMOTE_DIR }}
run: python3 scripts/deploy.py
2. The Python Deployment Script: scripts/deploy.py
Rather than relying on third-party marketplace actions with unpredictable version lock-ins, a concise Python script using standard library ftplib ensures zero external dependencies and fast, deterministic file uploads:
#!/usr/bin/env python3
import os
import sys
import ftplib
import argparse
import subprocess
import io
import time
DEFAULT_HOST = os.environ.get('FTP_HOST', 'ftp.example.com')
DEFAULT_USER = os.environ.get('FTP_USER', '')
DEFAULT_PASS = os.environ.get('FTP_PASSWORD', '')
DEFAULT_REMOTE_DIR = os.environ.get('FTP_REMOTE_DIR', '/public_html')
def ensure_remote_dir(ftp, remote_dir):
parts = [p for p in remote_dir.split('/') if p]
current = ''
for part in parts:
current += '/' + part
try:
ftp.cwd(current)
except ftplib.error_perm:
try:
ftp.mkd(current)
except Exception:
pass
def get_git_revision(repo_dir):
try:
rev = subprocess.check_output(
['git', '-C', repo_dir, 'rev-parse', 'HEAD'],
stderr=subprocess.DEVNULL
).decode().strip()
return rev
except Exception:
return 'unknown'
def deploy(host, user, password, remote_dir, dist_dir, repo_dir):
start_time = time.time()
print(f"==> Connecting to FTP {host} as {user}...")
ftp = ftplib.FTP()
ftp.connect(host, 21, timeout=30)
ftp.login(user, password)
ftp.set_pasv(True)
print("==> FTP Connected successfully.")
ensure_remote_dir(ftp, remote_dir)
total_files = 0
total_bytes = 0
print(f"==> Uploading files from {dist_dir} to {remote_dir}...")
for root, dirs, files in os.walk(dist_dir):
rel_path = os.path.relpath(root, dist_dir)
target_remote = remote_dir if rel_path == '.' else f"{remote_dir}/{rel_path.replace(os.sep, '/')}"
ensure_remote_dir(ftp, target_remote)
ftp.cwd(target_remote)
for file_name in files:
local_file = os.path.join(root, file_name)
file_size = os.path.getsize(local_file)
total_bytes += file_size
rel_file = os.path.join(rel_path, file_name) if rel_path != '.' else file_name
print(f" Uploading: {rel_file} ({file_size:,} bytes)")
with open(local_file, 'rb') as fp:
ftp.storbinary(f'STOR {file_name}', fp)
total_files += 1
# Update .deploy-revision marker
rev = get_git_revision(repo_dir)
try:
ftp.cwd(remote_dir)
bio = io.BytesIO(rev.encode('utf-8'))
ftp.storbinary('STOR .deploy-revision', bio)
print(f"==> Updated .deploy-revision with commit: {rev}")
except Exception as e:
print(f"Warning: Could not write .deploy-revision: {e}")
ftp.quit()
duration = time.time() - start_time
print(f"\n[SUCCESS] Deployed {total_files} files ({total_bytes / (1024*1024):.2f} MB) in {duration:.1f}s to {remote_dir}")
def main():
repo_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
dist_dir = os.path.join(repo_dir, 'dist')
deploy(DEFAULT_HOST, DEFAULT_USER, DEFAULT_PASS, DEFAULT_REMOTE_DIR, dist_dir, repo_dir)
if __name__ == '__main__':
main()
Step 8: Configuring Repository Secrets
Never hardcode passwords or private API tokens in git repositories or workflow YAML files. If your repository is ever shared or inspected, plaintext secrets will be permanently exposed in commit logs.
In Forgejo, inject your secrets securely:
- Navigate to Repo Settings → Actions → Secrets.
- Click Add Secret.
- Create the following secrets:
FTP_HOST: e.g.ftp.example.comFTP_USER: e.g.deploy_userFTP_PASSWORD: Your strong FTP user passwordFTP_REMOTE_DIR: e.g./public_htmlor/domains/example.com/public_html
These environment variables are injected into the runner’s ephemeral container memory at runtime and masked from job execution logs.
Step 9: Testing the Pipeline
Commit and push your changes to Forgejo:
git add .forgejo/workflows/deploy.yml scripts/deploy.py
git commit -m "ci: configure automated production deployment pipeline"
git push origin main
Navigate to the Actions tab in your repository. You will see your pipeline queued, started, and executed step-by-step:
✓ Checkout code (0.8s)
✓ Setup Node.js (1.2s)
✓ Install dependencies (3.4s)
✓ Build site (1.9s)
✓ Setup Python (0.5s)
✓ Deploy to FTP (19.6s)
==> Connecting to FTP ftp.example.com...
==> Uploading files...
==> Updated .deploy-revision with commit: bccecccd2c10eff...
[SUCCESS] Deployed 35 files (7.23 MB) in 19.6s
Bonus: Local Git Pre-Push Hook Fallback
In addition to running pipelines via server-side Actions runners, you can configure a pre-push hook (.git/hooks/pre-push) on your local workstation.
This guarantees that if your homelab runner is temporarily offline during maintenance, running git push from your terminal will still build and deploy the changes synchronously before transmitting the commit:
#!/bin/bash
# .git/hooks/pre-push
remote="$1"
url="$2"
while read local_ref local_sha remote_ref remote_sha
do
if [[ "$remote_ref" == "refs/heads/main" || "$local_ref" == "refs/heads/main" ]]; then
echo "==> Pushing to main branch. Running production deployment pipeline..."
npm run deploy
if [ $? -ne 0 ]; then
echo "Deployment failed! Aborting push."
exit 1
fi
fi
done
exit 0
Conclusion
Combining Forgejo Actions with a self-hosted Docker runner delivers the complete developer experience of modern enterprise platforms—without recurring cloud subscription fees, third-party vendor lock-in, or outbound data telemetry.
With this setup:
- Every commit pushed to
maintriggers an automatic, reproducible production build. - Production secrets remain safely encapsulated within Forgejo’s encrypted secret store.
- The
.deploy-revisiontracking file ensures complete traceability between running web assets and git commit SHAs.
Check out the previous installment in this series: Forgejo Installation Guide (MariaDB) to build your foundation, or explore our other infrastructure walkthroughs on Proxmox VE 9.2 Architecture.