Private GitOps CI/CD: How to Deploy Gitea and Gitea Actions with Docker Compose

DevOps engineer monitoring automated Gitea Actions CI/CD pipeline on workstation monitors
Private GitOps CI/CD: How to Deploy Gitea and Gitea Actions with Docker Compose 3

Modern software engineering workflows rely heavily on continuous integration and continuous delivery (CI/CD) pipelines to validate test suites, lint source code, build container images, and deploy artifacts to staging and production clusters. While cloud-hosted platforms such as GitHub and GitLab provide robust managed pipelines, they impose strict limits on free execution minutes, restrict runner compute specifications, and introduce regulatory compliance friction for proprietary intellectual property.

For organizations, homelabs, and sovereign development teams seeking total control over their source code and pipeline execution, Gitea offers an exceptionally lightweight, single-binary Git platform written in Go. With the introduction of Gitea Actions—a native CI/CD engine built upon the proven nektos/act execution engine—Gitea provides near 100% compatibility with existing GitHub Actions workflow definitions (.gitea/workflows/*.yaml). In this production deployment guide, you will deploy a high-availability Gitea instance backed by PostgreSQL, paired with an autonomous act_runner container orchestrated via Docker Compose, secured behind Caddy with automated TLS.

Architecture: Gitea Server & Act Runner Pipeline

Understanding how Gitea schedules and executes workflow jobs is essential for designing a secure and scalable CI/CD topology. The orchestration lifecycle separates the web UI and Git transport layers from the ephemeral execution containers.

+-----------------------------------------------------------------------------------+
|                        Developer Workstation / Git Client                         |
+-----------------------------------------------------------------------------------+
                       |                                    |
                       | HTTPS (Web / Git LFS)              | SSH (Port 2222)
                       v                                    v
+-----------------------------------------------------------------------------------+
|                           Reverse Proxy / Edge (Caddy)                            |
+-----------------------------------------------------------------------------------+
                                         |
                                         | Port 3000 (Internal Bridge)
                                         v
+-----------------------------------------------------------------------------------+
|                            Gitea Server (Core Engine)                             |
|  - Git Repositories, Issue Tracking, Release Assets, Actions Master Scheduler     |
+-----------------------------------------------------------------------------------+
                |                                                 |
                | PostgreSQL Connection                           | gRPC / REST Polling
                v                                                 v
+-------------------------------+              +------------------------------------+
| PostgreSQL 16 Enterprise DB   |              | Gitea Act Runner (Daemon Container)|
| - Relational User & Org Data  |              | - Polls Gitea for Queued Jobs      |
| - Commit Graphs & Pull Requests|             | - Mounts Docker Socket or DinD     |
+-------------------------------+              +------------------------------------+
                                                                  |
                                                                  | docker.sock API
                                                                  v
                                               +------------------------------------+
                                               | Ephemeral Job Execution Containers |
                                               | - node:20 / golang:1.24 / docker   |
                                               | - Executes steps: lint, test, build|
                                               +------------------------------------+

The system comprises three distinct operational domains:

  • Gitea Application Server: Serves the responsive web frontend, manages Git over HTTP/SSH, handles Webhooks, and acts as the workflow coordinator distributing queued tasks to registered runners.
  • PostgreSQL 16: Provides rock-solid ACID-compliant persistence for repositories, organization RBAC permissions, branch protection rules, and pipeline execution logs.
  • Gitea Act Runner: A daemon process that establishes an outbound connection to Gitea via gRPC/HTTP, claims matching tasks based on label tags (e.g., ubuntu-latest, docker), spins up clean ephemeral containers on the host Docker daemon, streams stdout logs in real-time, and captures return statuses.

Step 1: Host Directory Hierarchy and Service Account

Gitea should run with explicit UID and GID mapping to prevent arbitrary root privilege escalation inside containers. Create the dedicated directories on your host:

# Create application and persistence root
sudo mkdir -p /opt/gitea/{data,config,postgres,runner-data}

# Create a non-root system user for Gitea (UID 1000)
sudo groupadd -g 1000 git
sudo useradd -u 1000 -g git -m -s /bin/bash git

# Set strict ownership
sudo chown -R 1000:1000 /opt/gitea/data /opt/gitea/config
sudo chmod -R 750 /opt/gitea

Step 2: Environment Variables (.env)

Centralize your deployment parameters in /opt/gitea/.env. We will configure secure database credentials, external URL bindings, and runner registration defaults:

# ==============================================================================
# Gitea & Gitea Actions Configuration
# ==============================================================================

# General
TZ=UTC
GITEA_DOMAIN=git.yourdomain.com
GITEA_ROOT_URL=https://git.yourdomain.com/
GITEA_HTTP_PORT=3000
GITEA_SSH_PORT=2222

# PostgreSQL Database Configuration
POSTGRES_DB=gitea
POSTGRES_USER=gitea_user
POSTGRES_PASSWORD=SuperSecretDbPassword_Gitea_2026!
POSTGRES_HOST=gitea_db:5432

# Act Runner Configuration
# Token will be generated after initial Gitea launch from Web UI / CLI
GITEA_RUNNER_REGISTRATION_TOKEN=INITIAL_TOKEN_PLACEHOLDER
GITEA_RUNNER_NAME=primary-docker-runner
GITEA_RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:act-latest,ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04

Lock down read permissions on the environment file:

sudo chmod 600 /opt/gitea/.env

Step 3: Docker Compose Stack Definition

Create /opt/gitea/docker-compose.yml. This architecture defines the database, Gitea server, and the act runner container with access to the Docker socket for containerized pipeline tasks:

services:
  gitea-server:
    container_name: gitea_server
    image: docker.io/gitea/gitea:1.23-rootless
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=${POSTGRES_HOST}
      - GITEA__database__NAME=${POSTGRES_DB}
      - GITEA__database__USER=${POSTGRES_USER}
      - GITEA__database__PASSWD=${POSTGRES_PASSWORD}
      - GITEA__server__DOMAIN=${GITEA_DOMAIN}
      - GITEA__server__ROOT_URL=${GITEA_ROOT_URL}
      - GITEA__server__SSH_PORT=${GITEA_SSH_PORT}
      - GITEA__server__SSH_LISTEN_PORT=2222
      - GITEA__server__START_SSH_SERVER=true
      - GITEA__actions__ENABLED=true
      - GITEA__packages__ENABLED=true
    volumes:
      - /opt/gitea/data:/var/lib/gitea
      - /opt/gitea/config:/etc/gitea
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:${GITEA_HTTP_PORT}:3000"
      - "0.0.0.0:${GITEA_SSH_PORT}:2222"
    depends_on:
      gitea-db:
        condition: service_healthy
    networks:
      - gitea_network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 30s

  gitea-db:
    container_name: gitea_postgres
    image: docker.io/postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /opt/gitea/postgres:/var/lib/postgresql/data
    networks:
      - gitea_network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 3s
      retries: 5

  act-runner:
    container_name: gitea_act_runner
    image: docker.io/gitea/act_runner:0.2.11
    restart: unless-stopped
    environment:
      - GITEA_INSTANCE_URL=http://gitea-server:3000
      - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
      - GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME}
      - GITEA_RUNNER_LABELS=${GITEA_RUNNER_LABELS}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/gitea/runner-data:/data
    depends_on:
      gitea-server:
        condition: service_healthy
    networks:
      - gitea_network

networks:
  gitea_network:
    driver: bridge

Step 4: Launching Gitea & Registering the Act Runner

Before the runner can execute jobs, Gitea must be initialized to obtain a runner registration token. Follow this two-step bootstrapping procedure:

1. Start Gitea Server and Database

cd /opt/gitea
# Start only the server and database first
docker compose up -d gitea-db gitea-server

# Verify health status
docker compose ps

Navigate to https://git.yourdomain.com in your browser, complete the initial setup screen, and register your administrator user. Once logged in as admin, navigate to: Site Administration > Actions > Runners > Create new Runner.

Copy the displayed Registration Token (e.g., a9f8b7c6d5e4...).

2. Update Environment and Launch Act Runner

Edit /opt/gitea/.env and replace INITIAL_TOKEN_PLACEHOLDER with your real registration token. Then start the runner service:

# Start the Act Runner
docker compose up -d act-runner

# Check runner registration logs
docker compose logs -f act-runner

You should see confirmation output similar to:

level=info msg="Registering runner, name=primary-docker-runner, eval labels=[ubuntu-latest:docker://catthehacker/ubuntu:act-latest]..."
level=info msg="Runner registered successfully."
level=info msg="Starting runner daemon..."

Step 5: Testing with a Production CI/CD Workflow

Create a new repository in Gitea named sample-pipeline. In the root of the repository, add a pipeline definition at .gitea/workflows/ci.yaml:

name: Build, Lint, and Security Test

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Go runtime
        uses: actions/setup-go@v5
        with:
          go-version: '1.23'

      - name: Verify Go environment
        run: |
          go version
          echo "CI Pipeline running on $(uname -a)"

      - name: Execute automated unit tests
        run: |
          echo "Running mock unit tests..."
          test 1 -eq 1
          echo "All unit tests passed successfully."

  container-build:
    needs: lint-and-test
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Build Container Image Artifact
        run: |
          echo "Building lightweight Alpine container..."
          docker --version || echo "Docker daemon accessible for containerized builds"
          echo "Build finished at $(date -u)"

Commit and push the file. In Gitea, navigate to the Actions tab inside your repository. You will see your workflow picked up instantaneously by primary-docker-runner, with interactive colorized logs streamed directly into the browser interface.

Step 6: Caddy Reverse Proxy & SSH Routing Configuration

To expose Gitea with seamless HTTPS encryption and support large Git Large File Storage (LFS) payloads, configure Caddy in /etc/caddy/Caddyfile:

git.yourdomain.com {
    encode zstd gzip

    # Support large Git LFS binary uploads (game assets, ML models, ISOs)
    request_body {
        max_size 10GB
    }

    # Proxy web requests to Gitea server
    reverse_proxy 127.0.0.1:3000 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }

    # Security Headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
    }
}

Reload Caddy to apply changes: sudo systemctl reload caddy. For SSH cloning, developers will use: git clone ssh://git@git.yourdomain.com:2222/org/repo.git.

Security Hardening and Production Best Practices

1. Securing the Docker Socket Exposure

Mounting /var/run/docker.sock into act-runner grants root-equivalent control over the host Docker daemon. In multi-tenant environments or public repositories where untrusted contributors can submit Pull Requests, malicious workflow scripts could inspect host containers or read host mounts.

  • Private Homelabs & Small Teams: Mounting the socket is standard and delivers maximum build performance without nested virtualization overhead.
  • Public or Multi-Tenant Deployments: Configure Docker-in-Docker (DinD) or rootless Docker daemons inside an isolated network namespace, ensuring builds cannot escape their container cgroup boundaries.
  • Workflow Approval: In Gitea repository settings, enable “Require approval for pull requests from third-party contributors before running Actions”.

2. Automated PostgreSQL & Git Repository Dumps

Gitea includes a native backup utility that bundles database records, repositories, and custom configurations into a single compressed archive. Create /usr/local/bin/backup-gitea.sh:

#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/var/backups/gitea"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
mkdir -p "${BACKUP_DIR}"

# Execute Gitea native backup
docker exec -u 1000 -t gitea_server gitea dump -c /etc/gitea/app.ini -f "/tmp/gitea-dump-${TIMESTAMP}.zip"
docker cp "gitea_server:/tmp/gitea-dump-${TIMESTAMP}.zip" "${BACKUP_DIR}/gitea_backup_${TIMESTAMP}.zip"
docker exec -u 1000 -t gitea_server rm -f "/tmp/gitea-dump-${TIMESTAMP}.zip"

# Retain only last 7 days of backups
find "${BACKUP_DIR}" -type f -name "gitea_backup_*.zip" -mtime +7 -delete

echo "[$(date -u)] Gitea backup completed: gitea_backup_${TIMESTAMP}.zip"

Schedule this script via daily cron: sudo chmod +x /usr/local/bin/backup-gitea.sh.

Troubleshooting Typical CI/CD Failures

Issue 1: Runner Cannot Connect to Gitea (“connection refused” or “network unreachable”)

Symptom: The act-runner container crashes with: connect: connection refused or failed to ping gitea instance: dial tcp ...:3000.

Cause: GITEA_INSTANCE_URL is misconfigured to http://localhost:3000 or an external domain that Docker container DNS cannot resolve via loopback reflection.

Resolution: Inside Docker Compose, containers communicate via service names. Set GITEA_INSTANCE_URL=http://gitea-server:3000 in your compose configuration. Ensure both containers reside on the same bridge network (gitea_network).

Issue 2: Workflow Stuck in “Waiting for a runner to pick up this job”

Symptom: You trigger a pipeline with runs-on: ubuntu-latest, but the job status remains pending indefinitely.

Cause: The labels declared in the runner’s registration string do not match the workflow’s runs-on target.

Resolution: Verify your GITEA_RUNNER_LABELS parameter includes the required alias. To resolve ubuntu-latest, your runner must explicitly define: ubuntu-latest:docker://catthehacker/ubuntu:act-latest. Check active runner labels under Site Administration > Actions > Runners.

Issue 3: Permission Denied Accessing Docker Socket inside Workflow Containers

Symptom: Multi-stage Docker build jobs (e.g. running docker build inside a workflow step) fail with permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock.

Cause: The container spawned by act_runner runs as a non-root user (e.g. runner or node) that lacks read/write permissions on the host Docker socket (GID 999 or 998).

Resolution: Create a custom config.yaml for act-runner to mount the socket with relaxed permissions, or use docker-in-docker sidecars for container build jobs: add privileged: true inside the runner config file (/data/config.yaml) under container.privileged: true.

Conclusion: Fully Sovereign CI/CD Infrastructure

By pairing Gitea with Gitea Actions and Docker Compose, you have deployed a private, high-performance software forge that operates without external cloud dependencies. Your Git repositories remain completely on your own storage, your CI/CD test suites run on dedicated bare-metal or homelab hardware without minute limits, and developers enjoy familiar GitHub-compatible workflow syntax.

Next steps include configuring package registries (Docker Container Registry, npm, PyPI) within Gitea, integrating single sign-on (SSO) via Authentik or Keycloak, and defining automated deployment webhooks to trigger GitOps syncs in your K3s or Docker environments.