Back to all articles
• 9 min read •
#forgejo #cicd #docker #devops #homelab #linux

Automating CI/CD with Forgejo Actions: Docker Runner Setup & Real-World Website Deployment

A complete guide to configuring Forgejo Actions with a self-hosted Docker runner, managing repository secrets, and building an automated CI/CD pipeline to build and deploy an Astro portfolio website directly to production.

Table of Contents Tap to toggle

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:

  1. Enable Actions inside Forgejo.
  2. Install and register forgejo-runner with Docker support.
  3. Configure a persistent systemd daemon to handle jobs 24/7.
  4. 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.

  1. SSH into your Forgejo server.
  2. Edit /etc/forgejo/app.ini (or your custom configuration path, such as /home/git/forgejo/custom/conf/app.ini):
[actions]
ENABLED = true
  1. 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

  1. Download the latest official release binary of forgejo-runner from 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
  1. 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
  1. Generate a default configuration file:
forgejo-runner generate-config | sudo tee config.yaml > /dev/null
  1. Inspect config.yaml. The critical block is runner.labels, which maps workflow tags (like ubuntu-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"
💡 Custom Image Labels

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:

  1. Spawns an isolated container.
  2. Checks out the repository with full git history.
  3. Caches and installs npm dependencies.
  4. Compiles the Astro project into static HTML, CSS, and JS (dist/).
  5. Prepares a Python 3 environment.
  6. 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 Commit Production Credentials

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:

  1. Navigate to Repo Settings → Actions → Secrets.
  2. Click Add Secret.
  3. Create the following secrets:
    • FTP_HOST: e.g. ftp.example.com
    • FTP_USER: e.g. deploy_user
    • FTP_PASSWORD: Your strong FTP user password
    • FTP_REMOTE_DIR: e.g. /public_html or /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 main triggers an automatic, reproducible production build.
  • Production secrets remain safely encapsulated within Forgejo’s encrypted secret store.
  • The .deploy-revision tracking 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.