Build a Self-Hosted CI/CD Pipeline with Gitea Actions and Docker

Running CI/CD through GitHub Actions or GitLab CI is convenient until it isn’t. Free tier minute limits run out fast, private repositories cost more than you’d expect, and if your code is sensitive, you’re sending every push through someone else’s infrastructure. Self-hosting your pipeline sidesteps all of that.
Gitea is a lightweight, self-hosted Git service that has added GitHub Actions-compatible workflow support through a component called act_runner . The workflow YAML syntax is near-identical to GitHub Actions, so teams already familiar with that ecosystem can migrate with minimal friction. This guide walks through setting up a complete, production-ready CI/CD stack on Linux using Docker Compose.
Why Self-Host Your Pipeline
The practical case for self-hosting CI/CD comes down to three things: control, cost, and speed.
GitHub Actions gives free accounts 2,000 minutes per month for private repositories. An integration test suite that takes 8 minutes per run burns through that in about four days of active development. After that, you’re paying per minute or waiting until the month resets. GitLab CI has similar limits.
Control matters for a different reason. If your codebase contains proprietary algorithms, client data, or security tooling, pushing code through a third-party runner means that code is executed on hardware you don’t own, by processes you can’t inspect. A self-hosted runner keeps your source and build artifacts entirely on your own network.
Speed is often an underrated benefit. Cloud runners are shared infrastructure. Your build competes for CPU time with thousands of other jobs. On your own hardware - especially with an NVMe-backed Docker cache and a local container registry - builds that were taking 6-8 minutes on cloud runners often complete in 2-3 minutes.
Setting Up Gitea with Docker Compose
Before writing any workflow files, you need a running Gitea instance with Actions enabled. The fastest path is Docker Compose.
Create a project directory and a docker-compose.yaml:
version: "3.8"
networks:
gitea:
external: false
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__ROOT_URL=http://your-server-ip:3000/
- GITEA__server__SSH_DOMAIN=your-server-ip
- GITEA__actions__ENABLED=true
volumes:
- ./gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "222:22"
networks:
- gitea
restart: unless-stoppedThe critical environment variable here is GITEA__actions__ENABLED=true. Without it, the Actions tab won’t appear in the Gitea UI and act_runner won’t be able to register.
Start the stack with docker compose up -d, then navigate to http://your-server-ip:3000. The first-run wizard prompts you to create an admin account and set basic server settings. Once you’re logged in, you can create a repository and verify the Actions tab appears.

For production use, replace the SQLite database with PostgreSQL by adding a db service and updating the database environment variables accordingly (GITEA__database__DB_TYPE=postgres, GITEA__database__HOST=db:5432, and so on).
Installing and Configuring act_runner
act_runner is Gitea’s official runner daemon. It polls your Gitea instance for pending jobs, spins up Docker containers to execute workflow steps, and reports results back. Add it to your Docker Compose file:
runner:
image: gitea/act_runner:latest
container_name: act_runner
environment:
- GITEA_INSTANCE_URL=http://gitea:3000
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
volumes:
- ./runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
networks:
- gitea
depends_on:
- gitea
restart: unless-stoppedThe docker.sock mount is what allows the runner to spawn sibling containers for each job. This is a significant privilege - the runner can launch arbitrary Docker containers on your host. For that reason, keep your Gitea instance private or restrict runner registration to specific organizations and repositories.
To get the registration token, go to your Gitea admin panel under Site Administration > Runners. Copy the token, add it to a .env file next to your docker-compose.yaml:
RUNNER_TOKEN=your_token_hereThen restart the stack. The runner registers itself automatically on first boot. Confirm it’s online in the Gitea admin panel under Runners - it should show a green status indicator.
By default, act_runner accepts jobs tagged with ubuntu-latest, ubuntu-22.04, and ubuntu-20.04. These labels map the runner to workflows using the same runs-on: values. You can add custom labels in the runner’s config.yaml for more specific targeting.
Writing Your First Workflow
Gitea Actions workflow files live in .gitea/workflows/ inside your repository. The syntax mirrors GitHub Actions almost exactly - the same on:, jobs:, and steps: keys, the same uses: directive for third-party actions.
Here’s a complete workflow for a Python project that runs tests on every push:
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/ -v --tb=short
One thing to be aware of: third-party actions from GitHub Marketplace don’t work directly with Gitea, but most popular actions like actions/checkout, actions/setup-python, and actions/cache are mirrored on gitea.com and work without modification when you reference them as shown above. act_runner fetches them from the internet the first time and caches them locally.
For language-specific setups, replace the Python steps with whatever applies to your stack - actions/setup-node, a Go toolchain setup, or a simple Docker container via container: at the job level.
Building and Pushing Docker Images
One of the most common CI/CD tasks is building a Docker image and pushing it to a registry after tests pass. Gitea ships with a built-in OCI-compatible container registry (enabled by default), so you can push images to your own server without needing a separate Harbor or Docker Registry deployment.
Authenticate to the Gitea registry with docker login your-server-ip:3000. Your image names follow the pattern your-server-ip:3000/username/repo/image-name:tag. Before pushing, ensure your images follow security best practices—running as non-root, using minimal base images, and pinning dependencies. See our Docker image hardening checklist
for the full rundown.
Here’s a workflow that builds and pushes after a successful test run:
build-and-push:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ secrets.REGISTRY_URL }}/myapp:latest
${{ secrets.REGISTRY_URL }}/myapp:${{ github.sha }}Set REGISTRY_URL, REGISTRY_USER, and REGISTRY_PASSWORD in your repository’s Settings > Secrets > Actions. The runner substitutes them at job execution time and they never appear in logs.
Automating Deployment via SSH
A build pipeline that doesn’t deploy is just a test runner. The final step is getting your freshly built image onto the target server. The appleboy/ssh-action works with Gitea Actions and handles SSH connection, authentication, and remote command execution.
deploy:
runs-on: ubuntu-latest
needs: build-and-push
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ secrets.REGISTRY_URL }}/myapp:latest
docker compose -f /opt/myapp/docker-compose.yaml up -dStore your SSH private key as a secret (DEPLOY_SSH_KEY). The corresponding public key should be in ~/.ssh/authorized_keys on the deployment target. Use a dedicated, limited-privilege deploy user rather than root or your personal account.
The if: github.ref == 'refs/heads/main' condition restricts deployment to the main branch. Pushes to develop or feature branches run tests and builds, but only main triggers the actual deployment.
For a zero-downtime pattern, replace the docker compose up -d with a blue-green switch: bring up the new container on a different port, wait for a health check to pass, update your reverse proxy config
, then stop the old container.
Keeping Your Runner Secure and Efficient
A few practices separate a reliable self-hosted runner from a liability.
Scope your runner. A globally registered runner can pick up jobs from any repository on your Gitea instance. Unless you need that, register the runner to a specific organization or repository. This limits blast radius if a misconfigured workflow tries something it shouldn’t.
Set resource limits. Without constraints, a runaway build can consume all available CPU and memory on your host, taking down Gitea and every other service running alongside it. Add Docker resource limits to the runner container:
deploy:
resources:
limits:
cpus: "2.0"
memory: 4GYou can also set per-job container limits in the runner’s config.yaml under the container: key.
Cache aggressively. Use actions/cache to persist compiled assets, package manager caches, and Docker layer caches across runs. For Docker builds specifically, the docker/build-push-action supports cache-from and cache-to options that can halve build times after the first run.
Keep act_runner in sync with Gitea. The runner and server version numbering are linked. Running a significantly older act_runner against a newer Gitea version can cause subtle job failures. Pin both to the same release in your Compose file and update them together.
Log and alert. act_runner logs to stdout by default. Forward those logs to a log aggregation stack (Loki + Grafana is a natural fit if you’re already using it) and set up alerts for failed runs. A CI pipeline you can’t monitor isn’t much of a safety net. Once monitoring is in place, you can extend the pipeline further—for example, by adding automated code reviews with a local LLM that posts structured feedback on every pull request.
Handling Multi-Environment Workflows
Most real projects need more than one target: a staging environment that gets every commit to develop, and a production environment that only gets tagged releases. Gitea Actions handles this through conditional logic and environment protection rules.
Here’s a pattern that deploys to staging automatically and requires a manual approval step before production:
name: Deploy
on:
push:
branches: [develop, main]
create:
tags:
- "v*"
jobs:
deploy-staging:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Deploy to staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ secrets.REGISTRY_URL }}/myapp:develop
docker compose -f /opt/myapp/docker-compose.yaml up -d
deploy-production:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
environment: production
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ secrets.REGISTRY_URL }}/myapp:${{ github.ref_name }}
docker compose -f /opt/myapp/docker-compose.yaml up -dEnvironments in Gitea can have required reviewers configured under repository Settings > Environments. When a job targets an environment with reviewers, Gitea pauses execution and sends a notification. Someone on the review list approves or rejects the deployment before the job proceeds. This gives you a lightweight gating mechanism without a separate deployment orchestration tool.
Tagging a release and letting the pipeline handle promotion is cleaner than manually SSHing into production servers. It also creates a log of every deployment: who triggered it, when it ran, what image was deployed, and whether it succeeded.
Debugging Failed Runs
Pipelines fail. Knowing where to look when they do saves time.
The most common failure points in a fresh self-hosted setup are runner connectivity and Docker socket permissions. If jobs queue but never start, check that act_runner is running and registered: docker logs act_runner will show whether it’s polling Gitea successfully or hitting connection errors.
If a job starts but fails immediately with permission errors inside the container, the Docker socket mount may be owned by a group the runner user isn’t part of. On the host, run ls -la /var/run/docker.sock to see the owning group, then verify the container user has access.
For workflow logic failures, act_runner logs each step’s output in real time. The Gitea Actions UI shows the full log per step with exit codes. You can also enable debug logging for a run by adding ACTIONS_STEP_DEBUG: true to the workflow’s env: block - this produces verbose output from most actions and helps trace exactly where logic diverges from expectations.
When testing workflow changes, avoid committing repeatedly to trigger the pipeline. Instead, use act , a local workflow runner that simulates Gitea/GitHub Actions execution on your own machine. It isn’t a perfect replica, but it catches syntax errors, missing secrets, and basic step failures before you push.
Moving from GitHub Actions
If you have existing repositories on GitHub with Actions workflows, the migration path is straightforward for most projects. Copy your .github/workflows/ directory to .gitea/workflows/. The YAML syntax is compatible for all basic patterns.
The main areas to review:
- Actions that reference
github.com-hosted resources directly (like marketplace actions withuses: some-org/action@v1) may need to be replaced with Docker-based equivalents or mirrored Gitea actions, depending on whether the action maintainer has published togitea.com GITHUB_TOKENis replaced byGITEA_TOKENfor API access to the current repository- Some GitHub-specific contexts (
github.event.pull_request.head.repo.fork,github.event.repository.default_branch) have Gitea equivalents but may differ in naming or availability - Matrix strategies, service containers, and artifact upload/download all work, though the artifact storage is handled by Gitea rather than GitHub’s separate artifact service
For most application CI pipelines - build, test, containerize, deploy - the workflow file needs no changes at all beyond moving the directory. The divergence is mainly in workflows that rely on GitHub-specific integrations like GitHub Pages deployment or Packages-specific features.
For teams running internal infrastructure, the combination of Gitea’s code hosting , its built-in container registry, and act_runner means you can consolidate several managed services into a single self-hosted stack under your own control. Paired with a reverse proxy like Caddy or Nginx and automated TLS via Let’s Encrypt, the whole setup can run comfortably on a home lab machine or a modest VPS.
Botmonster Tech