First of all, what is Gitea?
Gitea is a lightweight, self-hosted Git service: repositories, issues, pull requests, packages and, since 1.21, GitHub-style Actions. With Gitea Runner (the act-based executor) you can keep the code and the CI/CD that builds it on the same server, without depending on an external forge.
This guide covers a generic deployment: run Gitea with a PostgreSQL database, register one or more Gitea Runner instances, and let a push trigger a workflow. We use the official Gitea image (single /data volume, built-in SSH server) while keeping the runner in its simple basic flavour. Everything is written so it works with plain Docker Compose or with a PaaS like Dokploy, and it is version-aware: Gitea 28.x, Gitea Runner 4.x (gitea/runner).
What are we building?
| Service | Image | Port | Role |
|---|---|---|---|
server | docker.gitea.com/gitea:28 | 3000 / 2222 (SSH) | Gitea web + built-in SSH |
db | postgres:17-alpine | 5432 (internal) | Gitea data store |
runner | docker.io/gitea/runner:4 | none | Executes Actions jobs |
The flow is simple: you push to a repository, Gitea hands the job to a runner that accepts the job's labels, the runner creates a container from the image declared by that label, runs the workflow inside it and reports the result back. Gitea itself never runs your code, and the runner has no public port.
Prerequisites
- Docker Engine + Docker Compose v2, or a Dokploy installation with a project and environment.
- A domain if you go through Dokploy (e.g.
git.example.com). - A reverse proxy that terminates HTTPS (Traefik in Dokploy, or Nginx/Caddy by hand).
Two version notes before we start:
- Gitea dropped the historical
1.prefix in 28.0.0;1.27.3is the last release of the old numbering. Pin an explicit tag (28, or a patch like28.0.0), neverlatest. - The runner was renamed.
gitea/act_runneris now deprecated and moved togitea/runner(current major is 4). Oldact_runnertags still exist but only receive legacy updates; usegitea/runner:4for new deployments.
About the image
Gitea ships both a rootful and a rootless image. This guide uses the standard rootful image (docker.gitea.com/gitea:28) together with USER_UID=1000 and USER_GID=1000, so the service inside the container does not run as root:
- All state lives in a single
/datavolume (repositories, LFS, packages, Actions data andapp.iniunder/data/gitea/conf/). - It uses its own built-in SSH server, listening on internal port 22; you publish it on the host (here
2222). - The image drops to the configured UID/GID, so you only have to make the host directory writable by that user.
Rootful and rootless images are not interchangeable once an instance is created, so pick one and stay on it. The official Docker guide is the reference for the details.
Step 1: Prepare the host
The Gitea container runs as UID/GID 1000, so its data directory must be owned by that user:
mkdir -p /opt/gitea/gitea /opt/gitea/postgres /opt/gitea/runner
sudo chown -R 1000:1000 /opt/gitea/gitea /opt/gitea/runnerbashIf the permissions are wrong, Gitea starts and fails on its first write:
mkdir: can't create directory '/data/git': Permission denied
/data/git is not writable
docker setup failedtextStep 2: The .env
Keep secrets out of the compose file:
# Gitea instance
GITEA_DOMAIN=git.example.com
GITEA_ROOT_URL=https://git.example.com/
GITEA_SSH_PORT=2222
# Database
POSTGRES_DB=gitea
POSTGRES_USER=gitea
POSTGRES_PASSWORD=replace-with-a-strong-password
# Runner (filled in Step 5, after the instance is up)
RUNNER_NAME=runner-1
RUNNER_LABELS=ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04envGITEA_ROOT_URL must be the public URL (the one users type), not localhost: Gitea builds clone URLs and webhook callbacks from it.
Step 3: docker-compose.yml
Gitea is configured entirely through environment variables of the form GITEA__<section>__<key>. The official image writes them into app.ini on startup, so no manual configuration file is needed.
services:
server:
image: docker.gitea.com/gitea:28
container_name: gitea
restart: always
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=db:5432
- 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_DOMAIN=${GITEA_DOMAIN}
- GITEA__server__SSH_PORT=${GITEA_SSH_PORT}
- GITEA__actions__ENABLED=true
volumes:
- /opt/gitea/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "${GITEA_SSH_PORT}:22"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:3000/api/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
db:
image: postgres:17-alpine
container_name: gitea-db
restart: always
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- /opt/gitea/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
runner:
image: docker.io/gitea/runner:4
container_name: gitea-runner
restart: always
environment:
- GITEA_INSTANCE_URL=${GITEA_ROOT_URL}
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_REGISTRATION_TOKEN}
- GITEA_RUNNER_NAME=${RUNNER_NAME}
- GITEA_RUNNER_LABELS=${RUNNER_LABELS}
volumes:
- /opt/gitea/runner:/data
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
server:
condition: service_healthyyamlNotes on the parts that bite:
- One volume: the official image keeps everything under
/data(repositories, LFS, packages, Actions data andapp.iniat/data/gitea/conf/app.ini). Back it up together with the database. - SSH port: the built-in SSH server listens on
22inside the container, so the host mapping is${GITEA_SSH_PORT}:22(here2222:22). depends_onwithcondition: the runner registers itself at startup. If Gitea is not yet answering, registration fails and the runner retries in a loop. The healthcheck makes startup deterministic under Compose v2.actions.ENABLED=true: Actions have been on by default since Gitea 1.21, but setting it explicitly documents intent and protects against anapp.inithat disables them.
Step 4: Start Gitea
docker compose up -d
docker compose psbashOpen https://git.example.com/ (or http://localhost:3000 directly) and complete the install page. Because the database is configured through the environment, the installer already knows the settings; just create the first administrator account.
Verify the API is healthy:
curl -fsS https://git.example.com/api/healthzbashStep 5: Obtain a registration token and start the runner
A registration token is what lets a runner join your instance. It can be scoped to the whole instance, to an organization, or to a single repository:
- Instance level:
https://git.example.com/-/admin/actions/runners - Organization level:
https://git.example.com/<org>/settings/actions/runners - Repository level:
https://git.example.com/<owner>/<repo>/settings/actions/runners
Copy the token and put it in .env:
RUNNER_REGISTRATION_TOKEN=D0gvfu2iHfUjNqCYVljVyRV14fISpJxxxxxxxxxxenvThen start the runner:
docker compose up -d runner
docker compose logs -f runnerbashYou should see it register and begin polling for jobs. Confirm it under Site administration: Actions: Runners: it appears as Idle.
The token stays valid until you reset it in the UI or API, and one token can register any number of runners. For disposable runners, set GITEA_RUNNER_EPHEMERAL=1; the container registers, runs exactly one job, and exits (no /data volume is needed, since the credentials are single-use). This is ideal for autoscaling in orchestration systems.
Runner labels and execution modes
Labels decide which jobs a runner accepts and how it runs them. The default labels point at node:16-*; the modern official images (docker.gitea.com/runner-images:ubuntu-*) bundle the usual tooling:
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-24.04
ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04
ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04textThe suffix selects the mode:
| Suffix | Mode | Docker daemon | Notes |
|---|---|---|---|
:docker://<image> | Docker (recommended) | external, via /var/run/docker.sock | isolated jobs, share the host daemon |
:dind://<image> | Docker-in-Docker | bundled in the -dind image | strongest isolation, needs --privileged |
:host | Host | host tools only | no isolation; use a distinct label |
To change labels later, edit runner.labels in the runner's config file (/data/config.yaml) or the GITEA_RUNNER_LABELS variable and restart. Starting with Gitea 1.21 a label change does not require re-registration.
Step 6: A workflow
Gitea reads workflows from .gitea/workflows/ (workflow_dispatch on the default branch also works). Here is a minimal build job that uses one of the labels above:
name: build
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm ci
- run: npm testyamlPush to the repository and open Actions: the job is queued, picked up by the runner, and executed in a fresh runner-images:ubuntu-24.04 container.
Gitea's
actengine covers the common Actions surface, not every GitHub quirk. If a job stays queued forever, no runner advertises itsruns-onlabel; that is the first thing to check.
Deploy with Dokploy
Dokploy runs the same compose file, but Traefik reaches the web container over its internal network, so you do not publish port 3000.
- Create a Compose service in your project.
- Paste the
docker-compose.ymlabove, remove the web3000:3000mapping, and keep the internal service names (server,db,runner). Keep the SSH mapping (2222:22) if you want Git over SSH. - Add the same environment variables in the Environment tab (
GITEA_DOMAIN,GITEA_ROOT_URL,POSTGRES_*,RUNNER_*). Do not putGITEA_ROOT_URL=http://server:3000there: it must be the public HTTPS URL. - Add a domain (
git.example.com) pointing to theserverservice on port3000. Traefik terminates HTTPS and issues the certificate. - If you need Git over SSH, publish
2222:22(or any free host port) and setGITEA_SSH_PORTto that host port; HTTP(S) clones work without it. - Deploy. Complete the installer, grab a registration token, set
RUNNER_REGISTRATION_TOKEN, then redeploy and watch the runner logs.
For the runner to build containers, it needs access to a Docker daemon. On a single-host Dokploy install the /var/run/docker.sock bind above is enough; for a remote or isolated builder, switch to the -dind image and give it privileged: true with its own volume.
Persistence and backups
Back up the Gitea volume together with the PostgreSQL database:
docker compose exec -T db pg_dump -U gitea gitea | gzip > gitea-$(date +%F).sql.gz
tar -czf gitea-data-$(date +%F).tar.gz -C /opt/gitea/gitea .bashTest the restore on a throwaway instance before you need it. A repository without its database is of no use, and vice versa.
Security notes
- Actions run arbitrary code. A runner that mounts
/var/run/docker.sockeffectively grants root on the host: only add trusted collaborators, and prefer a dedicated runner host or-dindwith--privilegedfor untrusted code. - Never expose
dborrunnerto the internet. The runner needs no inbound port. - Use a strong
POSTGRES_PASSWORDand a registration token scoped as low as possible (repository-level if a single repo needs CI). - Keep
/opt/gitea/giteawritable only by UID1000; the container drops toUSER_UID/USER_GID(1000) and does not run the service as root. - Register the token with
GITEA_RUNNER_REGISTRATION_TOKEN_FILE(Docker secrets) instead of a plain environment variable if your compose supports secrets.
Troubleshooting
The runner registers but shows as offline
Check that GITEA_INSTANCE_URL matches ROOT_URL and is reachable from inside the runner container. In Compose the runner can reach server on the internal network; a public hostname that resolves externally but not internally (split DNS) will break it.
docker compose exec runner wget -q -O- http://server:3000/api/healthzbashJobs stay queued and never start
Almost always a label mismatch: the workflow's runs-on does not match any registered label. Compare the job's label with Site administration: Actions: Runners and the RUNNER_LABELS value. Remember that changing labels may require a runner restart.
docker: command not found or cannot connect to the Docker daemon
The Docker-mode runner needs /var/run/docker.sock mounted and a reachable daemon. Check permissions:
docker compose logs runner
docker compose exec runner ls -l /var/run/docker.sockbashIf the daemon lives on another host, use the -dind image or point the runner at a remote daemon via DOCKER_HOST.
Gitea fails to start after changing the database
Look at app.ini inside the container: environment variables only override keys the image knows how to marshal. A malformed section/key silently keeps the old value. In the official image the file lives in /data/gitea/conf/app.ini:
docker compose exec server cat /data/gitea/conf/app.ini | head -40bashClones use the wrong URL
ROOT_URL is wrong or unset. It is baked into clone URLs, webhooks and emails. Set the public HTTPS URL and restart.
References
- Gitea: Install with Docker
- Gitea: Configuration cheat sheet
- Gitea Runner documentation
- Gitea Runner: register a runner
- Gitea Runner: install with Docker
- Gitea runner images
- Gitea on GitHub
AI-generated image disclosure
The cover image (/images/posts/gitea-runner-header.webp) was generated with the assistance of an AI model (deepseek-v4-flash). In accordance with Regulation (EU) 2024/1689 (EU Artificial Intelligence Act, Article 50), this content is disclosed as AI-generated. It is an original illustration created for this article without third-party images, logos or external assets; product names (Gitea, PostgreSQL, Docker, Dokploy) appear descriptively and no affiliation or endorsement is implied.
