Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install Gitea Actions Ubuntu
GUIDEInstall Guides

How to Install Gitea Actions on Ubuntu 24.04 VPS: GitHub Actions-Compatible CI/CD for Self-Hosted Git

31 min read

How to Install Gitea Actions on Ubuntu 24.04 VPS: GitHub Actions-Compatible CI/CD for Self-Hosted Git

Gitea Actions brings GitHub Actions-compatible CI/CD directly inside your self-hosted Gitea server. You write the same .yml workflows, use most of the same Marketplace actions, and still keep the entire pipeline, source code, artifacts, and secrets on infrastructure you own. This tutorial walks you through enabling Actions on an existing Gitea 1.21+ installation, installing the act_runner binary, registering it with a Docker executor, running your first build, and extending the setup to handle Kubernetes deployments.

By the end of this guide you will have a production-ready runner that survives reboots, a working sample workflow, a clear pattern for secrets, and a mental model for scaling to multiple runners when your team grows.

Table of Contents

  • What is Gitea Actions?
  • Why Self-Host Instead of Using GitHub Actions Minutes?
  • Prerequisites
  • Step 1: Enable Actions in app.ini
  • Step 2: Verify Actions Is Active
  • Step 3: Generate a Runner Registration Token
  • Step 4: Install the act_runner Binary
  • Step 5: Register the Runner with Gitea
  • Step 6: Run act_runner as a systemd Service
  • Step 7: Docker Executor vs Host Executor
  • Step 8: Create Your First Workflow
  • Step 9: Secrets and Variables
  • Step 10: Deploy to Kubernetes with docker-in-docker
  • Scaling to Multiple Runners
  • Troubleshooting
  • FAQ
  • Next Steps
  • What is Gitea Actions?

    Gitea Actions is the built-in CI/CD engine that ships with Gitea 1.19 and later (and is stable from 1.21 onward). It consists of two parts: the scheduler inside Gitea itself, which watches repositories for workflow files and queues jobs, and the act_runner process, which polls Gitea for jobs and executes them on a runner machine.

    The system is deliberately designed to be a drop-in alternative to GitHub Actions. Workflow files live at .gitea/workflows/*.yml, use the same on:, jobs:, steps:, uses:, and run: keys, and support the same expression syntax (${{ github.ref }}, ${{ secrets.MY_SECRET }}, ${{ matrix.os }}). Under the hood, act_runner is built on top of nektos/act, the same project that popularized running GitHub Actions locally, so compatibility with the Marketplace ecosystem is strong out of the box.

    Common workloads you can run on Gitea Actions:

    • Build and test pull requests on every push.
    • Publish Docker images to a registry when tags land on main.
    • Run static analysis (ESLint, Ruff, golangci-lint) and block merges on failure.
    • Deploy to staging on merge and to production on tag.
    • Schedule nightly jobs (on: schedule: with cron expressions).
    • Trigger reusable workflows shared across repositories.

    Why Self-Host Instead of Using GitHub Actions Minutes?

    GitHub Actions is convenient, but its pricing and constraints start to bite once a team grows past hobby scale. A self-hosted Gitea + act_runner stack on a CloudCore Professional VPS changes the economics and puts you back in control.

    • Flat-rate cost -- A VPS costs the same whether your pipelines run 10 minutes or 10,000 minutes per month. No per-minute meter, no overage alerts.
    • Private repos, unlimited build time -- GitHub's free tier gives 2,000 minutes per month for private repos. Self-hosted Gitea Actions has no minute limit.
    • Code stays inside your network -- Source, container images, and secrets never leave your VPS. Critical for regulated industries, government contractors, and anyone with an IP-sensitive codebase.
    • Choose your hardware -- Need 16 GB of RAM for a monorepo build? Need a local Docker cache to avoid pulling a 2 GB base image on every run? On a VPS you decide.
    • Direct network access -- Runners sit inside your network, so they can reach staging databases, internal package registries, and private Kubernetes clusters without VPNs or self-hosted GitHub runners with public ingress.
    • No rate limits -- GitHub throttles API calls from Actions runners. Your own Gitea has whatever limits you configure.
    • Deterministic build environments -- Pin your runner image, pin your toolchain versions, and stop chasing surprise breakages when GitHub upgrades ubuntu-latest.

    Cost comparison: GitHub Actions vs self-hosted Gitea Actions

    ScenarioGitHub Actions (Team plan)Self-Hosted Gitea + act_runner
    2,000 minutes/month (included)IncludedIncluded in VPS
    10,000 minutes/month~USD 64/mo in overageEUR 19.99/mo (unlimited)
    50,000 minutes/month~USD 384/mo in overageEUR 19.99/mo (unlimited)
    Secrets visible to GitHub?Yes (encrypted but on GitHub)No (stay on your VPS)
    Source code leaves network?YesNo
    Custom runner OS/packagesLimitedFull control
    Cold start time5-30sNear instant (warm container cache)
    For teams that already run Gitea, adding Actions is essentially free capacity on hardware you are already paying for.

    Prerequisites

    Before you begin, you need:

    • A VPS running Ubuntu 24.04 LTS with root or sudo access.
    • Gitea 1.21 or later already installed and reachable via HTTPS. If you do not have Gitea yet, follow our How to Install Gitea on Ubuntu 24.04 guide first.
    • Docker Engine installed on the runner host. If it is not, see How to Install Docker on Ubuntu 24.04.
    • At least 2 vCPU and 4 GB RAM on the runner host for basic pipelines. For container builds, Node/JS test suites, or Go compilation, 6 vCPU and 12 GB RAM is the sweet spot.
    • At least 40 GB of disk space for the runner's Docker image cache and workspace checkouts.
    • Administrator access to Gitea to edit app.ini and generate runner registration tokens.
    Recommended Plan: CloudCore Professional
    >
    For a combined Gitea server plus one act_runner handling a team of 5-15 developers, we recommend the CloudCore Professional plan:
    >
    - 6 vCPU cores
    - 12 GB RAM
    - 100 GB NVMe SSD
    - Unmetered bandwidth
    >
    This gives you headroom for Gitea itself, PostgreSQL, the Docker build cache, and two to three parallel CI jobs. For larger monorepos or heavy container builds, scale up to a plan with 16 GB+ RAM or dedicate a second VPS exclusively to the runner.

    Connect to your Gitea server via SSH:

    bash
    ssh root@your-gitea-server

    Confirm the installed Gitea version:

    bash
    sudo -u git /usr/local/bin/gitea --version

    Expected output:

    text
    Gitea version 1.21.11 built with GNU Make 4.3, go1.21.8 : bindata, sqlite, sqlite_unlock_notify

    Anything older than 1.21.x should be upgraded before enabling Actions, since earlier versions ship an experimental implementation.

    Step 1: Enable Actions in app.ini

    Gitea Actions is disabled by default. You enable it with a single block in the main configuration file. The default location on a package install is /etc/gitea/app.ini; on a binary install it is typically /var/lib/gitea/custom/conf/app.ini. Adjust the path below to match your setup.

    Open the config file:

    bash
    sudo nano /etc/gitea/app.ini

    Add (or uncomment) the following section anywhere in the file -- conventionally near the bottom:

    ini
    [actions]
    ENABLED = true
    DEFAULT_ACTIONS_URL = https://github.com

    Explanation:

    • ENABLED = true -- turns on the Actions scheduler inside Gitea.
    • DEFAULT_ACTIONS_URL = https://github.com -- tells act_runner where to resolve bare action references like uses: actions/checkout@v4. If a workflow says uses: actions/checkout@v4, the runner fetches https://github.com/actions/checkout at tag v4. You can change this to self to require users to mirror every action they use, which is the hardened posture for air-gapped environments.
    Save and close the file (Ctrl+O, Enter, Ctrl+X in nano).

    Restart Gitea for the change to take effect:

    bash
    sudo systemctl restart gitea

    Verify the service came back cleanly:

    bash
    sudo systemctl status gitea --no-pager

    Expected output:

    text
    ● gitea.service - Gitea (Git with a cup of tea)
         Loaded: loaded (/etc/systemd/system/gitea.service; enabled; preset: enabled)
         Active: active (running) since Wed 2026-04-16 10:00:00 UTC; 10s ago
       Main PID: 5678 (gitea)

    Step 2: Verify Actions Is Active

    Log in to the Gitea web UI as an administrator and open the Site Administration panel (/-/admin or the wrench icon at the top right).

    You should now see a new Actions section in the left sidebar with sub-pages for Runners and Tasks. If the section is missing, Actions did not load -- re-check your app.ini edit, confirm you restarted the right service, and check journalctl -u gitea --no-pager -n 100 for startup errors.

    You can also verify from the command line by visiting the API:

    bash
    curl -s https://gitea.yourdomain.com/api/v1/version

    Expected output:

    json
    {"version":"1.21.11"}

    And the Actions admin endpoint (requires an admin token):

    bash
    curl -s -H "Authorization: token YOUR_ADMIN_TOKEN" \
      https://gitea.yourdomain.com/api/v1/admin/runners

    Step 3: Generate a Runner Registration Token

    Runners authenticate to Gitea with a registration token. Tokens can be issued at three scopes:

  • Admin-level -- runner can pick up jobs from any repository on the instance. Best for a shared internal CI farm.
  • Organization-level -- runner serves all repositories in one organization.
  • Repository-level -- runner is scoped to a single repository. Most restrictive.
  • For this guide we use an admin-level token so the runner can serve any project.

    In the Gitea web UI:

  • Open Site Administration -> Actions -> Runners.
  • Click Create new Runner.
  • Copy the displayed token -- it looks like BxYz5A7nK2mQwE8tR6pV3cLhG1jFsD9u. You will use it exactly once during registration.
  • Tokens expire quickly (by default within an hour) and are single-use. If you do not finish registration in one sitting, just generate a new one.

    Step 4: Install the act_runner Binary

    act_runner is a single static Go binary. You can install it on the same VPS as Gitea (simplest) or on a separate VPS (recommended for production to isolate build workloads).

    Download the Latest Release

    Find the latest version at https://gitea.com/gitea/act_runner/releases. At the time of writing the current release is 0.2.11.

    bash
    cd /tmp
    curl -L -o act_runner \
      https://dl.gitea.com/act_runner/act_runner-0.2.11-linux-amd64
    chmod +x act_runner
    sudo mv act_runner /usr/local/bin/act_runner

    For ARM64 servers, change the filename to act_runner-0.2.11-linux-arm64.

    Verify:

    bash
    act_runner --version

    Expected output:

    text
    act_runner version v0.2.11

    Create a Dedicated User and Directories

    Running act_runner as root is a bad idea -- jobs that exploit a misconfigured container could escalate to root on the host. Create a dedicated user:

    bash
    sudo useradd --system --create-home --home-dir /var/lib/act_runner \
      --shell /usr/sbin/nologin act_runner

    If you will use the Docker executor (recommended), add the new user to the docker group so it can talk to the Docker socket:

    bash
    sudo usermod -aG docker act_runner

    Create the working directory:

    bash
    sudo mkdir -p /var/lib/act_runner/.cache
    sudo chown -R act_runner:act_runner /var/lib/act_runner

    Generate the Default Config

    bash
    sudo -u act_runner bash -c "cd /var/lib/act_runner && act_runner generate-config > /var/lib/act_runner/config.yaml"

    Open the file to review and tune:

    bash
    sudo nano /var/lib/act_runner/config.yaml

    The defaults are reasonable, but two sections matter most:

    yaml
    runner:
      file: .runner
      capacity: 2              # max concurrent jobs; set to vCPU count or less
      envs:
        A_TEST_ENV_NAME_1: a_test_env_value_1
      labels:
        - "ubuntu-latest:docker://node:20-bookworm"
        - "ubuntu-22.04:docker://node:20-bookworm"
        - "self-hosted"
      timeout: 3h
      insecure: false
      fetch_timeout: 5s
      fetch_interval: 2s

    container: network: "" privileged: false options: "" workdir_parent: "" valid_volumes: [] docker_host: "" force_pull: true

    Key points:

    • capacity: 2 -- two jobs run in parallel. Bump this up only if your VPS can comfortably handle it. Each container typically needs 1-2 GB RAM for a Node or Go build.
    • labels -- these are what a workflow's runs-on: key matches against. The prefix (ubuntu-latest) is the label, and the suffix after the colon (docker://node:20-bookworm) is the image used when the Docker executor picks this label. Add a host label like - "self-hosted-host" (with no image suffix) if you want a host-executor option.
    • privileged: false -- leave this false for general workflows. Flip to true only for runners dedicated to container-building jobs that need docker-in-docker.
    • force_pull: true -- always pulls the latest image tag before a job starts. Safer, at the cost of cold-start time.
    Save and close.

    Step 5: Register the Runner with Gitea

    With the binary, user, and config in place, register the runner against your Gitea instance. Use the token you generated in Step 3.

    bash
    sudo -u act_runner bash -c "cd /var/lib/act_runner && act_runner register \
      --no-interactive \
      --instance https://gitea.yourdomain.com \
      --token BxYz5A7nK2mQwE8tR6pV3cLhG1jFsD9u \
      --name ci-runner-01 \
      --labels ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://node:20-bookworm,self-hosted"

    Expected output:

    text
    INFO Registering runner, arch=amd64, os=linux, version=v0.2.11.
    DEBU Successfully pinged the Gitea instance server
    INFO Runner registered successfully.

    Two new files are created in /var/lib/act_runner:

    • .runner -- a JSON file with the runner's UUID, name, token for future auth, and labels. Protect this file: anyone with it can impersonate the runner.
    • config.yaml -- the one you already edited.
    Confirm the runner is visible in the web UI at Site Administration -> Actions -> Runners. It should show ci-runner-01 with status Offline (it will flip to Idle once the service is running).

    Step 6: Run act_runner as a systemd Service

    To keep the runner alive across reboots and crashes, wrap it in systemd.

    Create the unit file:

    bash
    sudo tee /etc/systemd/system/act_runner.service > /dev/null <<'EOF'
    [Unit]
    Description=Gitea Actions Runner (act_runner)
    Documentation=https://docs.gitea.com/usage/actions/overview
    After=network.target docker.service
    Requires=docker.service

    [Service] Type=simple User=act_runner Group=act_runner WorkingDirectory=/var/lib/act_runner ExecStart=/usr/local/bin/act_runner daemon --config /var/lib/act_runner/config.yaml Restart=on-failure RestartSec=10 KillMode=process

    Hardening

    NoNewPrivileges=true PrivateTmp=true ProtectSystem=full ProtectHome=true

    [Install] WantedBy=multi-user.target EOF

    Reload systemd, enable, and start the service:

    bash
    sudo systemctl daemon-reload
    sudo systemctl enable --now act_runner

    Check status:

    bash
    sudo systemctl status act_runner --no-pager

    Expected output:

    text
    ● act_runner.service - Gitea Actions Runner (act_runner)
         Loaded: loaded (/etc/systemd/system/act_runner.service; enabled; preset: enabled)
         Active: active (running) since Wed 2026-04-16 10:15:00 UTC; 3s ago
       Main PID: 6789 (act_runner)
          Tasks: 8 (limit: 14236)
         Memory: 25.0M

    Stream the logs to watch it poll Gitea:

    bash
    sudo journalctl -u act_runner -f

    You should see lines like:

    text
    INFO[0000] Starting runner daemon
    INFO[0000] Runner is ready to accept jobs
    DEBU[0002] poll: no available job

    Return to the web UI. The runner should now be Idle.

    Step 7: Docker Executor vs Host Executor

    act_runner supports two execution modes, selected per job via labels in your workflow's runs-on: key.

    Docker Executor (recommended)

    Labels configured with a :docker://... image suffix use the Docker executor. Each job:

  • Pulls the image specified in the label (e.g. node:20-bookworm).
  • Starts a fresh container from it.
  • Mounts a workspace directory into /workspace.
  • Runs your steps inside the container.
  • Destroys the container and cleans up volumes when the job ends.
  • Pros: Clean isolation, reproducible, matches GitHub Actions behaviour closely, trivial to pin toolchain versions. Cons: Slightly slower cold start, uses more disk for images.

    Use this for 95% of workflows. Label examples:

    yaml
    labels:
      - "ubuntu-latest:docker://node:20-bookworm"
      - "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
      - "golang:docker://golang:1.22-bookworm"

    The catthehacker/ubuntu images are community-maintained and include most tools you would find on a GitHub-hosted runner. They are the recommended default when you want workflows to "just work" without customizing the image per job.

    Host Executor

    Labels configured without an image suffix use the host executor. Jobs run directly on the VPS as the act_runner user -- no container.

    Pros: Zero cold start, full access to preinstalled tooling, can reach host services (databases on localhost), and is the natural fit for deploy jobs that need rsync, ssh, or kubectl from the VPS. Cons: Cross-job contamination (leftover files, installed packages, mutated PATH), security exposure (a malicious PR could read the runner user's home directory), and you must preinstall every tool yourself.

    Register labels with no image suffix for this mode:

    yaml
    labels:
      - "self-hosted-host"
      - "deploy-target"

    A typical pattern is to register two runners: a Docker-executor runner labelled ubuntu-latest for building/testing, and a small host-executor runner labelled deploy-target for the final deploy step. Your workflow then uses runs-on: ubuntu-latest for build jobs and runs-on: deploy-target for the deploy job.

    Step 8: Create Your First Workflow

    With a runner online, create a workflow inside any Gitea repository you own.

    Workflow files live under .gitea/workflows/ -- note the .gitea prefix (not .github). Gitea Actions will ignore .github/workflows/ by default, although you can enable it in app.ini with [actions] DEFAULT_WORKFLOW_DIRS = .gitea/workflows,.github/workflows if you are migrating a repo from GitHub.

    Create .gitea/workflows/ci.yml:

    yaml
    name: CI

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

    jobs: test: runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4

    - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "20"

    - name: Install dependencies run: npm ci

    - name: Run tests run: npm test

    - name: Upload coverage if: always() uses: actions/upload-artifact@v3 with: name: coverage path: coverage/

    Commit and push. Within a second or two Gitea dispatches the job, your runner picks it up, and the Actions tab on the repository shows a live log.

    Workflow anatomy -- every key here is identical to GitHub Actions:

    • on: -- triggers. Supports push, pull_request, schedule, workflow_dispatch, workflow_call, release, issues, and more.
    • jobs: -- a map of job IDs to definitions.
    • runs-on: -- matches one of your runner's labels.
    • steps: -- ordered list. Each is either uses: (an action) or run: (a shell command).
    • if: -- expression gate on steps or jobs.
    • env: -- per-step or per-job environment variables.
    • matrix: -- strategy for running the same job across multiple versions or OSes.

    Matrix Example

    yaml
    jobs:
      test:
        runs-on: ubuntu-latest
        strategy:
          fail-fast: false
          matrix:
            node: ["18", "20", "22"]
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: ${{ matrix.node }}
          - run: npm ci && npm test

    This runs three parallel jobs, one per Node version. Because your runner has capacity: 2, two run at once and the third queues.

    Services Example

    Spin up a Postgres sidecar for integration tests:

    yaml
    jobs:
      integration:
        runs-on: ubuntu-latest
        services:
          postgres:
            image: postgres:16
            env:
              POSTGRES_PASSWORD: testpass
            ports:
              - 5432:5432
            options: >-
              --health-cmd pg_isready
              --health-interval 10s
              --health-timeout 5s
              --health-retries 5
        steps:
          - uses: actions/checkout@v4
          - run: npm ci
          - run: npm run test:integration
            env:
              DATABASE_URL: postgres://postgres:testpass@postgres:5432/postgres

    Step 9: Secrets and Variables

    Workflows frequently need credentials -- deploy keys, registry passwords, API tokens. Gitea supports the same three-tier secret system as GitHub Actions.

    Where to Store Secrets

  • Repository secrets -- scope to a single repo. Settings -> Actions -> Secrets -> Add Secret.
  • Organization secrets -- shared across all repos in one org. Organization settings -> Actions -> Secrets.
  • Admin-level secrets -- instance-wide. Site Administration -> Actions -> Secrets.
  • Secrets are encrypted at rest in the Gitea database and redacted in logs automatically. They are only injected into jobs that explicitly reference them via ${{ secrets.NAME }}.

    Using Secrets in a Workflow

    yaml
    jobs:
      publish:
        runs-on: ubuntu-latest
        if: github.ref == 'refs/heads/main'
        steps:
          - uses: actions/checkout@v4

    - name: Log in to private registry run: echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}

    - name: Build and push image run: | docker build -t registry.example.com/myapp:${{ github.sha }} . docker push registry.example.com/myapp:${{ github.sha }}

    Variables (Non-Secret Config)

    For non-sensitive config like environment names, region slugs, or feature flags, use variables (vars.*) instead of secrets. Variables appear in logs, so use them for anything you would commit to the repo otherwise.

    yaml
    env:
      DEPLOY_REGION: ${{ vars.DEPLOY_REGION }}
      APP_ENV: ${{ vars.APP_ENV }}

    Secret Hygiene Checklist

    • Use organization secrets for shared credentials (Docker Hub, npm, Sentry) so rotation happens in one place.
    • Use repository secrets for repo-specific deploy keys.
    • Never log a secret with echo $SECRET -- Gitea will redact it, but a leaked set -x can still print enough context to be dangerous.
    • Rotate secrets after every team departure. Gitea has no audit log for secret reads, so assume any compromised runner means every secret it had access to is compromised.

    Step 10: Deploy to Kubernetes with docker-in-docker

    A common advanced use case: build a container image in CI and deploy it to a private Kubernetes cluster. With a Gitea Actions runner that has Docker access, this is straightforward.

    Workflow Pattern

    yaml
    name: Build and Deploy

    on: push: branches: [main]

    jobs: build-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4

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

    - name: Log in to registry uses: docker/login-action@v3 with: registry: registry.example.com username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }}

    - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: registry.example.com/myapp:${{ github.sha }} cache-from: type=registry,ref=registry.example.com/myapp:buildcache cache-to: type=registry,ref=registry.example.com/myapp:buildcache,mode=max

    - name: Install kubectl uses: azure/setup-kubectl@v4 with: version: "v1.30.0"

    - name: Decode kubeconfig run: | mkdir -p ~/.kube echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config chmod 600 ~/.kube/config

    - name: Deploy run: | kubectl set image deployment/myapp \ myapp=registry.example.com/myapp:${{ github.sha }} \ -n production kubectl rollout status deployment/myapp -n production --timeout=5m

    Storing the Kubeconfig as a Secret

    On your workstation:

    bash
    base64 -w 0 ~/.kube/config

    Copy the output into a Gitea secret named KUBECONFIG_B64 at the org or repo level. In the workflow we decode it back into ~/.kube/config inside the container.

    Docker-in-Docker Considerations

    The steps above rely on the runner having a working Docker client. Because we mounted the host's /var/run/docker.sock by adding act_runner to the docker group, the job's docker build effectively runs on the host's Docker daemon -- technically Docker-out-of-Docker. This is faster and safer than true DinD (running a privileged inner Docker daemon), but it means build artifacts and images live in the host's Docker store.

    If you need true isolation (for example, building untrusted third-party code), set privileged: true in config.yaml and add a dedicated docker:dind service in the workflow:

    yaml
    services:
      dind:
        image: docker:26-dind
        env:
          DOCKER_TLS_CERTDIR: ""
        options: --privileged

    Then set DOCKER_HOST=tcp://dind:2375 in your build steps.

    Running act_runner Inside Kubernetes

    For production-grade K8s deployments, run the runner itself inside the cluster as a Deployment. This gives jobs direct network access to internal services and no need for a bastion VPS. A minimal manifest:

    yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: act-runner
      namespace: ci
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: act-runner
      template:
        metadata:
          labels:
            app: act-runner
        spec:
          containers:
            - name: runner
              image: gitea/act_runner:0.2.11-dind
              securityContext:
                privileged: true
              env:
                - name: GITEA_INSTANCE_URL
                  value: https://gitea.yourdomain.com
                - name: GITEA_RUNNER_REGISTRATION_TOKEN
                  valueFrom:
                    secretKeyRef:
                      name: runner-token
                      key: token
                - name: GITEA_RUNNER_NAME
                  valueFrom:
                    fieldRef:
                      fieldPath: metadata.name
                - name: CONFIG_FILE
                  value: /config/config.yaml
              volumeMounts:
                - name: config
                  mountPath: /config
                - name: runner-data
                  mountPath: /data
          volumes:
            - name: config
              configMap:
                name: act-runner-config
            - name: runner-data
              emptyDir: {}

    Use the -dind image variant so each pod ships with an embedded Docker daemon. Size the Deployment replicas to match peak concurrency.

    Scaling to Multiple Runners

    A single runner with capacity: 2 is fine for a small team. Once you have more than a handful of developers, scale horizontally rather than vertically.

    Adding a Second Runner

  • Provision a new VPS (or a new pod in K8s).
  • Install Docker and the act_runner binary exactly as above.
  • Generate a new registration token (single-use) in the Gitea admin UI.
  • Run act_runner register with a different --name and the same labels as the first runner.
  • Enable the systemd service.
  • Gitea automatically load-balances queued jobs across all runners that match the runs-on label, round-robin style. There is no central scheduler to configure.

    Label Strategy for Heterogeneous Pools

    As you grow, specialize runners:

    • ubuntu-latest and ubuntu-22.04 -- general-purpose Docker runners.
    • gpu -- runner on a GPU VPS for ML workflows.
    • deploy-prod -- locked-down host-executor runner that only admins can target.
    • arm64 -- ARM VPS for multi-arch image builds.
    Workflows then opt in via runs-on: gpu or runs-on: [self-hosted, deploy-prod].

    Troubleshooting

    ProblemCauseSolution
    Runner shows Offline in the UIsystemd service crashed or cannot reach Giteasudo journalctl -u act_runner -n 100 --no-pager. Check instance URL and TLS cert validity.
    Error: failed to ping Gitea instance: 401 UnauthorizedRegistration token expired or already usedGenerate a fresh token in the admin UI and re-register.
    Jobs stay Queued foreverNo runner matches the runs-on labelCompare workflow runs-on: to the runner's configured labels. Labels are case-sensitive.
    Cannot connect to the Docker daemon at unix:///var/run/docker.sockact_runner user not in docker groupsudo usermod -aG docker act_runner && sudo systemctl restart act_runner.
    Action actions/checkout@v4 fails with git: command not foundRunner image has no GitSwitch to catthehacker/ubuntu:act-22.04 or add RUN apt-get install -y git to a custom image.
    Secrets show as empty string in logsSecret defined at wrong scope or typo in nameCheck Settings -> Actions -> Secrets at repo, org, and admin level. Secret names are uppercase by convention and case-sensitive.
    Error response from daemon: pull access denied when using a private registryNo docker login step before pullAdd docker/login-action@v3 with registry credentials before the pull.
    Builds run out of disk after a few daysDocker image cache on the runner never cleans upAdd a weekly cron: docker system prune -af --filter "until=168h".
    ubuntu-latest resolves to a stale imageforce_pull: false plus tag reuseSet force_pull: true in config.yaml or pin labels to digest-based image refs.

    Useful Debugging Commands

    bash
    # Stream runner logs
    sudo journalctl -u act_runner -f

    List running job containers

    docker ps --filter "label=com.github.actions.job_id"

    Check disk usage for Docker

    docker system df

    Validate workflow YAML locally before pushing

    act --list -W .gitea/workflows/ci.yml

    FAQ

    Is Gitea Actions compatible with GitHub Actions workflows?

    Gitea Actions uses the same YAML syntax as GitHub Actions and can execute most actions from the GitHub Marketplace through the compatibility layer in act_runner. Workflows live under .gitea/workflows/ and support on, jobs, steps, matrix, services, and reusable workflows. A small number of GitHub-specific features -- OIDC federation tokens, the gh CLI's built-in auth, and Marketplace actions that call the GitHub REST API -- are not fully supported, but core CI/CD patterns (build, test, lint, publish, deploy) work without modification. For actions that call github.api, replace them with gitea equivalents or raw curl against the Gitea API.

    Should I use the Docker executor or the host executor for act_runner?

    Use the Docker executor for almost every case. It sandboxes each job in a container, reproduces the GitHub Actions environment closely, and cleans up between runs, so jobs cannot contaminate each other. The host executor runs jobs directly on the VPS -- faster and useful for deploy steps that already need the host's SSH keys, kubectl, or rsync -- but it mixes build state across jobs and gives every workflow full access to the runner user's home directory. The pragmatic pattern is to register labels for both modes and select them via runs-on:. Use Docker labels like ubuntu-latest for tests and builds, and reserve a host-executor label like deploy-prod for the final deploy step.

    Why self-host Gitea Actions instead of using GitHub Actions minutes?

    Self-hosting removes per-minute billing, unlocks unlimited private repository CI on a flat-rate VPS, keeps source code and secrets inside your network, and lets you size the runner for your workload. For teams that burn more than 2,000 build minutes per month, a CloudCore Professional VPS running Gitea plus act_runner is typically cheaper than GitHub-hosted minutes while also giving you full control over the toolchain (pin Node versions, preinstall system packages, use a local Docker image cache). You also avoid GitHub's rate limits on API-heavy actions and get direct network access to internal services like staging databases and private Kubernetes clusters without opening them to the public internet.

    Can Gitea Actions deploy to Kubernetes?

    Yes. Register a runner with the Docker executor, mount the Docker socket (or use docker-in-docker with privileged: true), and use kubectl or helm inside the workflow. Store the kubeconfig as a Gitea secret (base64-encode it first to avoid YAML issues), decode it in a step, and run kubectl apply or helm upgrade. For heavier workloads, run act_runner inside the cluster itself as a Kubernetes Deployment so jobs have direct pod-to-pod network access to internal services and can use an in-cluster ServiceAccount instead of a stored kubeconfig. The official gitea/act_runner:<version>-dind image includes a Docker daemon and is the recommended starting point.

    How do I scale act_runner horizontally?

    Each act_runner instance pulls one or more jobs at a time based on its capacity setting. To add more throughput, install act_runner on additional VPS nodes, register each with the same Gitea instance using fresh registration tokens, and give them matching labels. Gitea distributes queued jobs across available runners that match the runs-on label, round-robin style with no central scheduler to configure. You can also run multiple runner processes on one large VPS with separate config.yaml files, separate .runner files, and separate systemd units -- useful when you want to dedicate capacity to certain label pools. For Kubernetes, run act_runner as a Deployment with replicas: N and autoscale with the horizontal pod autoscaler.

    Do I need a separate VPS for the runner, or can it share the Gitea server?

    For personal projects and small teams, a shared VPS is fine -- Gitea itself uses modest resources and a CloudCore Professional plan has plenty of headroom for both Gitea and a runner with capacity: 2. For production, move the runner to a dedicated VPS. Reasons: build jobs can consume all CPU and memory at once (starving Gitea), a compromised runner is less dangerous if it cannot read Gitea's database directly, and you can scale runner hardware independently of Git hosting. Many teams put Gitea on a small VPS and the runner on a larger one with more cores and disk.

    How do I mirror GitHub actions when my network blocks github.com?

    Set DEFAULT_ACTIONS_URL = self in app.ini. With that set, every uses: reference must point to a repository on your own Gitea instance (e.g. uses: mirror/actions-checkout@v4 where mirror/actions-checkout is a mirror you maintain). Gitea supports repository mirroring natively, so you can mirror actions/checkout, actions/setup-node, and any other actions your workflows depend on. This is the standard pattern for air-gapped environments and government deployments.

    Next Steps

    With Gitea Actions live, here is how to get the most out of the setup:

    • Install Gitea on Ubuntu -- If you landed here but have not set up the Gitea server itself, start with How to Install Gitea on Ubuntu 24.04.
    • Harden Docker for CI workloads -- Review the Docker daemon configuration, set up log rotation, and enable image signing. See How to Install Docker on Ubuntu 24.04 for the baseline.
    • Compare with Jenkins -- If your team is weighing alternatives, our How to Install Jenkins on Ubuntu 24.04 guide covers the classic CI option and its tradeoffs against Actions-style workflows.
    • Read the official Actions docs -- The Gitea Actions Overview covers advanced features like reusable workflows, workflow dispatch inputs, and concurrency groups.
    • Set up runner monitoring -- Expose act_runner metrics with a sidecar node-exporter, scrape with Prometheus, and alert on queue depth or job failure rate.
    • Add caching -- Speed up repeat builds with actions/cache@v4. The same action works transparently against Gitea's built-in cache service.
    • Automate runner provisioning -- Once you understand the manual flow, script it with cloud-init or Ansible so adding a new runner is a one-command operation.

    Run Gitea and act_runner on a Production-Grade VPS
    >
    Our CloudCore Professional plan gives you enough headroom for Gitea, PostgreSQL, and a two-job Docker runner on one box -- or split across two smaller VPSes for better isolation.
    >
    - 6 vCPU cores
    - 12 GB RAM
    - 100 GB NVMe SSD
    - Unmetered bandwidth
    - Full root access and one-click Ubuntu 24.04 LTS
    >
    Deploy your CI/CD VPS now and replace per-minute CI billing with a flat EUR 19.99/month.

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket