← All articles
black computer tower on white table

Building a Complete Homelab CI/CD Pipeline with Gitea Actions

DevOps 2026-02-15 · 12 min read gitea ci-cd devops docker pipeline automation proxmox
By HomeLab Starter Editorial TeamHome lab enthusiasts covering hardware setup, networking, and self-hosted services for home and small office environments.

Getting Gitea Actions running is one thing. Building a pipeline that actually handles the full lifecycle -- build, test, package, deploy, and notify -- is another. Most CI/CD guides stop at "here is how to run tests on push." That is only the first stage. A real homelab pipeline builds Docker images, pushes them to a private registry, deploys to your Proxmox VMs or Docker hosts, and tells you whether it worked.

Photo by Nathan Anderson on Unsplash

This guide builds a complete multi-stage pipeline from scratch. We assume you already have Gitea installed with Actions enabled and at least one runner registered. If not, set those up first -- the focus here is on the pipeline itself, not Gitea installation.

Gitea logo

Pipeline Architecture

A production-grade homelab pipeline has five stages:

  1. Build -- Compile code, bundle assets, produce artifacts
  2. Test -- Run unit tests, integration tests, linting
  3. Package -- Build Docker images, push to registry
  4. Deploy -- Update running services on target hosts
  5. Notify -- Send success/failure notifications

Each stage gates the next. If tests fail, no image gets built. If the image push fails, nothing deploys. If deployment fails, you get a notification instead of silence.

Runner Configuration for Pipelines

Before writing workflows, your runner needs to support Docker-in-Docker (DinD) for building container images. The default Gitea act runner can run in Docker mode, which gives each job a fresh container, but that container needs Docker access to build images.

Create a runner configuration that supports DinD:

# runner-config.yaml
runner:
  file: .runner
  capacity: 4
  timeout: 60m
  labels:
    - "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
    - "self-hosted"

container:
  network: "host"
  privileged: true
  valid_volumes:
    - "/var/run/docker.sock"
    - "/home/runner/cache"
  docker_host: "unix:///var/run/docker.sock"
  options: |
    -v /var/run/docker.sock:/var/run/docker.sock
    -v /home/runner/cache:/cache

Register the runner with this config:

# Register runner (interactive)
./act_runner register \
  --instance https://gitea.homelab.local \
  --token YOUR_RUNNER_TOKEN \
  --config runner-config.yaml \
  --name homelab-runner

# Start the runner
./act_runner daemon --config runner-config.yaml

The key settings: privileged: true enables DinD, the Docker socket mount lets jobs build images using the host Docker daemon, and capacity: 4 allows four concurrent jobs.

Stage 1: Build and Test Workflow

Start with a workflow that builds and tests on every push and pull request. This is the foundation everything else builds on.

# .gitea/workflows/ci.yaml
name: CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: registry.homelab.local:5000
  IMAGE_NAME: ${{ gitea.repository }}

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true

      - name: Lint
        uses: golangci/golangci-lint-action@v4
        with:
          version: latest

  test:
    runs-on: ubuntu-latest
    needs: lint
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true

      - name: Run unit tests
        run: go test -v -race -coverprofile=coverage.out ./...

      - name: Run integration tests
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable
        run: go test -v -tags=integration ./tests/...

      - name: Upload coverage
        uses: actions/upload-artifact@v3
        with:
          name: coverage
          path: coverage.out

  build:
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

This workflow does three things in sequence: lint the code, run unit and integration tests (with a PostgreSQL service container), and build a Docker image that gets pushed to your private registry. The build step only runs on pushes to main, not on pull requests. The cache-from and cache-to directives use the registry as a build cache, which dramatically speeds up subsequent builds.

Service Containers

Gitea Actions supports service containers just like GitHub Actions. The PostgreSQL service in the test job gives integration tests a real database without requiring one to be pre-configured on the runner. Other common service containers:

services:
  redis:
    image: redis:7
    ports:
      - 6379:6379

  minio:
    image: minio/minio
    env:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    ports:
      - 9000:9000
    options: >-
      --health-cmd "curl -f http://localhost:9000/minio/health/live"
      --health-interval 10s
      --health-timeout 5s

Stage 2: Multi-Architecture Builds

If your homelab runs a mix of x86 and ARM hardware (Proxmox nodes and Raspberry Pis, for example), you need multi-arch images. Gitea Actions supports this through Docker Buildx with QEMU emulation.

  build-multiarch:
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Build and push (multi-arch)
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}

Multi-arch builds via QEMU emulation are slower than native builds (3-5x typically). For frequently-built projects, consider running dedicated ARM runners on your ARM hardware. This avoids emulation entirely.

Like what you're reading? Subscribe to HomeLab Starter — free weekly guides in your inbox.

Stage 3: Deployment Workflows

Building and testing is half the pipeline. Deployment is where most homelab setups fall short. Here are three deployment strategies for common homelab targets.

Deploying to Docker Compose Hosts

The most common homelab deployment: SSH into a host, pull the new image, and restart the service.

  deploy-docker:
    runs-on: ubuntu-latest
    needs: build
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/myapp
            docker compose pull
            docker compose up -d --remove-orphans
            # Wait for health check
            sleep 10
            docker compose ps --format json | jq -e '.[] | select(.Health == "healthy")'
            if [ $? -ne 0 ]; then
              echo "Health check failed, rolling back"
              docker compose rollback 2>/dev/null || docker compose up -d --force-recreate
              exit 1
            fi
            # Clean up old images
            docker image prune -f

The health check after deployment catches failed startups. If the new container is not healthy after 10 seconds, it rolls back to the previous version. Adjust the sleep duration based on your application's startup time.

Deploying to Proxmox VMs

For applications running inside Proxmox VMs (not containers), use SSH to deploy directly to the VM:

  deploy-proxmox-vm:
    runs-on: ubuntu-latest
    needs: build
    environment: production
    steps:
      - name: Checkout deployment scripts
        uses: actions/checkout@v4
        with:
          sparse-checkout: deploy/

      - name: Deploy to VM
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VM_HOST }}
          username: deploy
          key: ${{ secrets.VM_SSH_KEY }}
          script: |
            # Pull latest image inside the VM
            docker login -u ${{ secrets.REGISTRY_USER }} \
              -p ${{ secrets.REGISTRY_PASSWORD }} \
              registry.homelab.local:5000

            docker pull registry.homelab.local:5000/myapp:latest

            # Update systemd service
            sudo systemctl restart myapp

            # Verify service is running
            sleep 5
            systemctl is-active --quiet myapp || exit 1

Deploying Proxmox LXC Containers via API

For Proxmox LXC containers, you can use the Proxmox API to trigger updates without SSH:

  deploy-proxmox-lxc:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Trigger container update
        run: |
          # Snapshot before updating
          curl -s -k \
            -H "Authorization: PVEAPIToken=${{ secrets.PROXMOX_TOKEN }}" \
            -X POST \
            "https://proxmox.homelab.local:8006/api2/json/nodes/pve/lxc/101/snapshot" \
            -d "snapname=pre-deploy-$(date +%s)"

          # Execute update script inside LXC
          curl -s -k \
            -H "Authorization: PVEAPIToken=${{ secrets.PROXMOX_TOKEN }}" \
            -X POST \
            "https://proxmox.homelab.local:8006/api2/json/nodes/pve/lxc/101/exec" \
            -d "command=bash /opt/deploy/update.sh"

Stage 4: Registry Integration

A private container registry is essential for a self-hosted pipeline. You need somewhere to store your built images that your deployment targets can pull from.

Gitea's Built-in Registry

Gitea includes a container registry out of the box. Enable it in app.ini:

[packages]
ENABLED = true

Then push images to gitea.homelab.local/owner/repo/image:tag. This is the simplest option -- no extra services to run.

Harbor for Advanced Features

For more control (vulnerability scanning, replication, RBAC), use Harbor:

# Harbor in your pipeline
env:
  REGISTRY: harbor.homelab.local

# In your build step:
- name: Login to Harbor
  uses: docker/login-action@v3
  with:
    registry: harbor.homelab.local
    username: ${{ secrets.HARBOR_USER }}
    password: ${{ secrets.HARBOR_PASSWORD }}

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: harbor.homelab.local/homelab/${{ env.IMAGE_NAME }}:${{ gitea.sha }}

Harbor scans pushed images for vulnerabilities automatically. You can configure policies to prevent deploying images with critical CVEs.

Registry Cleanup

Container registries grow fast. Add a periodic cleanup job:

# .gitea/workflows/registry-cleanup.yaml
name: Registry Cleanup

on:
  schedule:
    - cron: '0 3 * * 0'  # Weekly at 3 AM Sunday

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Clean old tags
        run: |
          # Keep last 10 tags, delete the rest
          REPO="homelab/myapp"
          TAGS=$(curl -s "https://harbor.homelab.local/v2/${REPO}/tags/list" \
            -u "${{ secrets.HARBOR_USER }}:${{ secrets.HARBOR_PASSWORD }}" \
            | jq -r '.tags[]' | sort -V | head -n -10)

          for TAG in $TAGS; do
            DIGEST=$(curl -s -I \
              "https://harbor.homelab.local/v2/${REPO}/manifests/${TAG}" \
              -u "${{ secrets.HARBOR_USER }}:${{ secrets.HARBOR_PASSWORD }}" \
              -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
              | grep docker-content-digest | awk '{print $2}' | tr -d '\r')

            curl -s -X DELETE \
              "https://harbor.homelab.local/v2/${REPO}/manifests/${DIGEST}" \
              -u "${{ secrets.HARBOR_USER }}:${{ secrets.HARBOR_PASSWORD }}"

            echo "Deleted ${REPO}:${TAG}"
          done

Stage 5: Notifications

Silent pipelines are useless pipelines. If a deployment fails at 2 AM, you need to know about it before users start complaining.

ntfy Integration

ntfy is a lightweight push notification service that runs perfectly on homelab hardware. Add notifications to your pipeline:

  notify:
    runs-on: ubuntu-latest
    needs: [deploy-docker]
    if: always()
    steps:
      - name: Notify on success
        if: needs.deploy-docker.result == 'success'
        run: |
          curl -s \
            -H "Title: Deploy Successful" \
            -H "Priority: default" \
            -H "Tags: white_check_mark" \
            -d "Deployed ${{ gitea.repository }}@${{ gitea.sha }} to production" \
            https://ntfy.homelab.local/deployments

      - name: Notify on failure
        if: needs.deploy-docker.result == 'failure'
        run: |
          curl -s \
            -H "Title: Deploy FAILED" \
            -H "Priority: urgent" \
            -H "Tags: x" \
            -d "Failed to deploy ${{ gitea.repository }}@${{ gitea.sha }}. Check Actions log: ${{ gitea.server_url }}/${{ gitea.repository }}/actions" \
            https://ntfy.homelab.local/deployments

Gotify Integration

Gotify is another popular self-hosted notification service:

      - name: Notify via Gotify
        if: always()
        run: |
          STATUS="${{ needs.deploy-docker.result }}"
          if [ "$STATUS" = "success" ]; then
            TITLE="Deploy OK"
            PRIORITY=5
          else
            TITLE="Deploy FAILED"
            PRIORITY=8
          fi

          curl -s "https://gotify.homelab.local/message?token=${{ secrets.GOTIFY_TOKEN }}" \
            -F "title=${TITLE}" \
            -F "message=Repository: ${{ gitea.repository }}, Commit: ${{ gitea.sha }}, Status: ${STATUS}" \
            -F "priority=${PRIORITY}"

Matrix/Discord Webhooks

For team homelabs, send notifications to a chat channel:

      - name: Notify Matrix
        if: always()
        run: |
          STATUS="${{ needs.deploy-docker.result }}"
          MSG="**Pipeline ${STATUS}** for \`${{ gitea.repository }}\` @ \`${{ gitea.sha }}\`"

          curl -s -X PUT \
            -H "Authorization: Bearer ${{ secrets.MATRIX_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d "{\"msgtype\": \"m.text\", \"body\": \"${MSG}\", \"format\": \"org.matrix.custom.html\", \"formatted_body\": \"${MSG}\"}" \
            "https://matrix.homelab.local/_matrix/client/v3/rooms/${{ secrets.MATRIX_ROOM }}/send/m.room.message/$(date +%s)"

Complete Pipeline: Putting It All Together

Here is a complete workflow that combines all five stages into a single pipeline file:

# .gitea/workflows/pipeline.yaml
name: Complete Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: harbor.homelab.local
  IMAGE_NAME: homelab/${{ gitea.event.repository.name }}

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: golangci-lint run ./...
      - run: go test -v -race -coverprofile=coverage.out ./...
      - run: go test -v -tags=integration ./tests/...
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable

  build-and-push:
    runs-on: ubuntu-latest
    needs: lint-and-test
    if: github.event_name == 'push'
    outputs:
      image_tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

  deploy:
    runs-on: ubuntu-latest
    needs: build-and-push
    environment: production
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/${{ gitea.event.repository.name }}
            docker compose pull
            docker compose up -d --remove-orphans
            sleep 15
            if ! docker compose ps --format json | jq -e 'all(.Health == "healthy" or .Health == "")'; then
              echo "DEPLOY FAILED: unhealthy containers detected"
              exit 1
            fi
            docker image prune -f

  notify:
    runs-on: ubuntu-latest
    needs: [lint-and-test, build-and-push, deploy]
    if: always()
    steps:
      - name: Determine status
        id: status
        run: |
          if [ "${{ needs.deploy.result }}" = "success" ]; then
            echo "status=success" >> $GITHUB_OUTPUT
            echo "emoji=white_check_mark" >> $GITHUB_OUTPUT
            echo "priority=default" >> $GITHUB_OUTPUT
          elif [ "${{ needs.deploy.result }}" = "skipped" ]; then
            echo "status=skipped" >> $GITHUB_OUTPUT
            echo "emoji=fast_forward" >> $GITHUB_OUTPUT
            echo "priority=low" >> $GITHUB_OUTPUT
          else
            FAILED=""
            [ "${{ needs.lint-and-test.result }}" = "failure" ] && FAILED="tests"
            [ "${{ needs.build-and-push.result }}" = "failure" ] && FAILED="build"
            [ "${{ needs.deploy.result }}" = "failure" ] && FAILED="deploy"
            echo "status=failed (${FAILED})" >> $GITHUB_OUTPUT
            echo "emoji=x" >> $GITHUB_OUTPUT
            echo "priority=urgent" >> $GITHUB_OUTPUT
          fi

      - name: Send notification
        run: |
          curl -s \
            -H "Title: Pipeline ${{ steps.status.outputs.status }}" \
            -H "Priority: ${{ steps.status.outputs.priority }}" \
            -H "Tags: ${{ steps.status.outputs.emoji }}" \
            -H "Click: ${{ gitea.server_url }}/${{ gitea.repository }}/actions" \
            -d "[${{ gitea.repository }}] Commit ${{ gitea.sha }} - Pipeline ${{ steps.status.outputs.status }}" \
            https://ntfy.homelab.local/ci-cd

Pipeline Caching

Build speed depends heavily on caching. Without caches, every pipeline run downloads dependencies, rebuilds Docker layers, and recompiles everything from scratch.

Go Module Cache

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true  # Caches ~/go/pkg/mod and ~/.cache/go-build

Node.js / npm Cache

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

Docker Layer Cache via Registry

The cache-from and cache-to directives in the build-push-action store Docker layer caches in your registry. This means subsequent builds only rebuild changed layers:

      - uses: docker/build-push-action@v5
        with:
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

Local Runner Cache

For runners with persistent storage, mount a cache directory:

# In runner-config.yaml
container:
  valid_volumes:
    - "/home/runner/cache"
  options: |
    -v /home/runner/cache:/cache

Then use it in workflows:

      - name: Restore cache
        run: |
          if [ -d /cache/go-mod ]; then
            cp -r /cache/go-mod ~/go/pkg/mod
          fi

      - name: Save cache
        if: always()
        run: |
          mkdir -p /cache/go-mod
          cp -r ~/go/pkg/mod/* /cache/go-mod/ 2>/dev/null || true

Secrets Management

Gitea Actions supports repository and organization-level secrets. Set them through the Gitea UI or API:

# Set a secret via API
curl -X PUT \
  -H "Authorization: token YOUR_GITEA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data": "my-secret-value"}' \
  "https://gitea.homelab.local/api/v1/repos/owner/repo/actions/secrets/SECRET_NAME"

Required secrets for a typical pipeline:

Secret Purpose
REGISTRY_USER Container registry username
REGISTRY_PASSWORD Container registry password
DEPLOY_HOST Target host for deployment
DEPLOY_USER SSH username for deployment
DEPLOY_SSH_KEY SSH private key for deployment
NTFY_TOPIC Notification topic (if using auth)
PROXMOX_TOKEN Proxmox API token (if deploying to Proxmox)

Generate dedicated deploy keys rather than reusing personal SSH keys:

ssh-keygen -t ed25519 -f deploy_key -C "gitea-deploy"
# Add deploy_key.pub to target host's authorized_keys
# Add deploy_key (private) as a Gitea secret

Monitoring Your Pipeline

Runner Health

Check runner status via the Gitea API:

curl -s \
  -H "Authorization: token YOUR_TOKEN" \
  "https://gitea.homelab.local/api/v1/repos/owner/repo/actions/runners" \
  | jq '.[] | {name, status, busy}'

Pipeline Metrics

Track pipeline execution time and success rate. Add a final step that records metrics to your monitoring stack:

      - name: Record metrics
        if: always()
        run: |
          # Push pipeline duration to Prometheus Pushgateway
          DURATION=$(($(date +%s) - ${{ gitea.event.head_commit.timestamp }}))
          cat <<PROM | curl -s --data-binary @- http://pushgateway.homelab.local:9091/metrics/job/gitea-pipeline/repo/${{ gitea.repository }}
          pipeline_duration_seconds ${DURATION}
          pipeline_success{repo="${{ gitea.repository }}"} ${{ needs.deploy.result == 'success' && '1' || '0' }}
          PROM

Common Failures and Fixes

Docker socket permission denied: The runner container needs access to /var/run/docker.sock. Ensure the socket mount is in valid_volumes and the runner runs as root or in the docker group.

Registry TLS errors: If your registry uses self-signed certificates, add --insecure-registry to the Docker daemon config on the runner host, or mount the CA certificate into the runner container.

SSH key authentication failures: Ensure the deploy key is in OpenSSH format (not PuTTY), has correct permissions (600), and the target host has the public key in authorized_keys. Test manually first:

ssh -i deploy_key deploy@target-host "echo ok"

Workflow not triggering: Check that .gitea/workflows/ (not .github/workflows/) is the correct directory for your Gitea version. Gitea 1.21+ supports both paths, but older versions only read .gitea/workflows/.

Timeout on large builds: Increase the runner timeout in runner-config.yaml and set timeout-minutes in the workflow job.

A complete CI/CD pipeline turns your homelab from a collection of manually-updated services into an automated platform where pushing code results in deployed, tested, and monitored applications. The initial setup takes a few hours, but every subsequent deployment takes minutes and happens without manual intervention.

Get free weekly tips in your inbox. Subscribe to HomeLab Starter