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?

ServiceImagePortRole
serverdocker.gitea.com/gitea:283000 / 2222 (SSH)Gitea web + built-in SSH
dbpostgres:17-alpine5432 (internal)Gitea data store
runnerdocker.io/gitea/runner:4noneExecutes 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.3 is the last release of the old numbering. Pin an explicit tag (28, or a patch like 28.0.0), never latest.
  • The runner was renamed. gitea/act_runner is now deprecated and moved to gitea/runner (current major is 4). Old act_runner tags still exist but only receive legacy updates; use gitea/runner:4 for 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 /data volume (repositories, LFS, packages, Actions data and app.ini under /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/runnerbash

If 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 failedtext

Step 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.04env

GITEA_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_healthyyaml

Notes on the parts that bite:

  • One volume: the official image keeps everything under /data (repositories, LFS, packages, Actions data and app.ini at /data/gitea/conf/app.ini). Back it up together with the database.
  • SSH port: the built-in SSH server listens on 22 inside the container, so the host mapping is ${GITEA_SSH_PORT}:22 (here 2222:22).
  • depends_on with condition: 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 an app.ini that disables them.

Step 4: Start Gitea

docker compose up -d
docker compose psbash

Open 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/healthzbash

Step 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=D0gvfu2iHfUjNqCYVljVyRV14fISpJxxxxxxxxxxenv

Then start the runner:

docker compose up -d runner
docker compose logs -f runnerbash

You 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.04text

The suffix selects the mode:

SuffixModeDocker daemonNotes
:docker://<image>Docker (recommended)external, via /var/run/docker.sockisolated jobs, share the host daemon
:dind://<image>Docker-in-Dockerbundled in the -dind imagestrongest isolation, needs --privileged
:hostHosthost tools onlyno 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 testyaml

Push 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 act engine covers the common Actions surface, not every GitHub quirk. If a job stays queued forever, no runner advertises its runs-on label; 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.

  1. Create a Compose service in your project.
  2. Paste the docker-compose.yml above, remove the web 3000:3000 mapping, and keep the internal service names (server, db, runner). Keep the SSH mapping (2222:22) if you want Git over SSH.
  3. Add the same environment variables in the Environment tab (GITEA_DOMAIN, GITEA_ROOT_URL, POSTGRES_*, RUNNER_*). Do not put GITEA_ROOT_URL=http://server:3000 there: it must be the public HTTPS URL.
  4. Add a domain (git.example.com) pointing to the server service on port 3000. Traefik terminates HTTPS and issues the certificate.
  5. If you need Git over SSH, publish 2222:22 (or any free host port) and set GITEA_SSH_PORT to that host port; HTTP(S) clones work without it.
  6. 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 .bash

Test 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.sock effectively grants root on the host: only add trusted collaborators, and prefer a dedicated runner host or -dind with --privileged for untrusted code.
  • Never expose db or runner to the internet. The runner needs no inbound port.
  • Use a strong POSTGRES_PASSWORD and a registration token scoped as low as possible (repository-level if a single repo needs CI).
  • Keep /opt/gitea/gitea writable only by UID 1000; the container drops to USER_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/healthzbash

Jobs 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.sockbash

If 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 -40bash

Clones 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

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.