Building a Complete Homelab CI/CD Pipeline with Gitea Actions
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.
Pipeline Architecture
A production-grade homelab pipeline has five stages:
- Build -- Compile code, bundle assets, produce artifacts
- Test -- Run unit tests, integration tests, linting
- Package -- Build Docker images, push to registry
- Deploy -- Update running services on target hosts
- 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.
