Let the Process Remember the Rules: A Small-Team Workflow on Forgejo

Small teams rarely fail for lack of skill. They fail because nobody’s job is to remember the rules. Three to five people (plus a few AI agents) edit the same configuration. Who touched production, why it was changed, and which trap someone fell into last time all live in one person’s head or in a chat log that has long since scrolled away.

My conclusion: don’t rely on people to remember rules — put them in the process. The issue records why, the PR records how and how it was verified, CI blocks the silly mistakes, and documents in the repo remember the pitfalls. This post collects how I build that process with Forgejo and the Forgejo Actions runner. (If terms like issue and PR are new to you, section 2 starts with an explanation.)


1. Why self-host Forgejo

Forgejo is a community-governed Git service (a fork of Gitea). A single binary or container gives you repositories, issues, pull requests, a package registry and CI (Forgejo Actions, nearly identical in syntax to GitHub Actions).

GitHub / GitLab.com Self-hosted GitLab Self-hosted Forgejo
Resources Not your problem 4 GB RAM and up One container + a database, a few hundred MB idle
Private repos, CI Priced and capped by plan Yes Yes
Where the data lives Someone else’s data center Yours Yours
Your responsibility Almost none A lot Backups, upgrades, exposure

On a small server that also runs websites, Forgejo’s footprint is the deciding factor. But self-hosting means three jobs are yours, so decide who owns them before you start:

  1. Backups: repositories, the database and configuration need off-site copies — and you need to have restored from them at least once.
  2. Upgrades: pin the major version and read the release notes before moving; never use latest.
  3. Exposure: turn off entry points you don’t use (open registration, OpenID sign-in), allow SSH keys only, and keep admin pages off the open internet.

2. The workflow: issue → branch → PR → merge → deploy

An analogy: editing a shared document

Imagine a team maintaining an important document together, such as the company handbook:

  • Someone notices a section needs changing and first opens a to-do ticket describing what should change and why. That’s an issue.
  • Whoever takes it doesn’t edit the official version directly; they make a copy and edit that. That’s a branch.
  • When the edits are done, they submit them for review: the differences between the copy and the official version go to the others to check. That’s a PR (pull request — “please pull my changes into the official version”).
  • On submission, automatic checks run first, like a spell checker or format checker, so obvious mistakes are caught before anyone reads it. That’s CI.
  • Once approved, the copy’s changes are folded into the official version. That’s a merge.
  • Finally the new version is printed and handed out. That’s a deploy: the change actually takes effect.

The official version is always one that has been reviewed and checked, and every change leaves a record of why it was made, who looked at it and what the checks said. That is the heart of the whole process.

Term In one sentence
repo (repository) All of a project’s files and their complete history of changes
commit One save: which files changed, who changed them and why
branch A copy split off from the official version; changes there don’t affect it. The official version is usually called main or master
issue A to-do or problem ticket: what to do, why, and the discussion and decision
PR (pull request) A review request: asks to merge one branch’s changes into the official version, with a description, and is where the discussion happens
review Others read the PR’s changes and comment or approve
CI (continuous integration) Checks and tests that run automatically every time changes are pushed, with results shown on the PR
runner The program that actually runs CI: it takes jobs from Forgejo and reports back
merge Folding a reviewed branch into the official version
deploy Putting the official version on a server so users actually get it

GitHub, GitLab and Forgejo all share these ideas; only the names and interfaces differ slightly (GitLab calls a PR a merge request).

The workflow in practice

flowchart LR
    I["Issue<br/>why"] --> B["Branch<br/>one change each"]
    B --> P["PR<br/>how, and how verified"]
    P --> C{"CI checks"}
    C -- "fail" --> B
    C -- "pass" --> R["A human reviews<br/>and says merge"]
    R --> M["Merge"]
    M --> D["Deploy<br/>by someone with access"]
    D --> L["Record<br/>CHANGELOG, docs"]

Each step has a purpose:

  • The issue says why. The problem, the numbers, the option chosen and why the others weren’t. Six months later, this is the most valuable part.
  • One change per branch. A mixed PR can’t be half reverted.
  • The PR says how, and how it was verified. What was tested, the deploy command, the expected output. Reviewers shouldn’t have to guess.
  • Know the difference between Closes #12 and Refs #12. An issue that tracks follow-ups (“check the numbers in a week”) must be referenced with Refs, or merging the PR closes it and the follow-up disappears.
  • Merging and deploying are separate. A merge means the code is agreed on; deploying to production is another action that needs confirmation — especially anything that needs root, which should be done by someone with access, after a backup.
  • Always record the result. Write the real numbers after deploying (memory, response times, error counts) back into the CHANGELOG, so next time there’s a baseline.

3. Write the rules down where people and AI agents can see them

Teams now often have AI agents changing code. They start from zero every time: a rule that isn’t written down doesn’t exist for them. The same goes for a new colleague.

I keep an AGENTS.md (or README) in every important directory, with the same few sections:

# forgejo-runner

| Path | Contents |
|---|---|
| docker-compose.yml | DinD and the runner |
| config.yml | one job at a time, 30-minute timeout |
| data/token | the runner token — secret, not in git |

## Isolation, and why
- Jobs don't get the host's docker.sock: that would give them root on the host

## Don't
- Don't put deployment in a workflow
- Don't change valid_volumes to '**'

## Known pitfalls
- After editing config.yml, always --force-recreate (2026-11: an edit didn't take effect)

Three principles:

  1. Why matters more than how. Write only “don’t use X” and the next person will think it’s overcautious and change it. Write “what happened when we used X” and nobody will touch it.
  2. Write the pitfall down the moment you hit it. Don’t wait to “tidy up later” — later never comes.
  3. The CHANGELOG records numbers and decisions, not just changes. “Raised PHP workers from 8 to 12” is useless. “Workers maxed out dozens of times in a week, almost all from crawlers, so no extra workers — rate-limit at the edge instead” lets the next person judge whether the decision still holds.

4. Runner architecture and isolation

A CI runner executes workflows written by anyone with access to a repository. It is the security boundary people most often forget.

Don’t mount the host’s docker.sock

The most common tutorial mounts /var/run/docker.sock into the runner. That hands every job root on the host: a job can start a privileged container and mount the host’s /.

The safer setup is Docker-in-Docker (DinD): the runner talks to a separate Docker daemon, and job containers run inside it, away from the host’s files and keys.

flowchart TB
    subgraph Host["Host"]
        F["Forgejo"]
        subgraph Net["runner network"]
            R["forgejo-runner<br/>polls Forgejo, dispatches jobs"]
            subgraph D["DinD (privileged, 1 CPU, 1 GB)"]
                J1["job container<br/>unprivileged, no volumes"]
            end
        end
    end
    R -- "unix socket<br/>(shared volume)" --> D
    R -- "HTTPS" --> F
    J1 -- "checkout (HTTPS)" --> F

Key points:

  • DinD exposes only a unix socket, not TCP 2375. Many examples use DOCKER_HOST=tcp://docker:2375, but then a job container can reach the Docker API through DinD’s gateway and start a privileged container to escape. Share the socket with the runner through a volume and jobs can’t reach it.
  • DinD’s resource limits cover every job. Give DinD mem_limit and cpus; however hard CI runs, production keeps the rest. On a small host, set capacity to 1.
  • Job container restrictions live in the runner config: no privileged mode, no volume mounts, no Docker socket.
# docker-compose.yml (excerpt)
services:
  dind:
    image: docker:29.9.2-dind          # pinned version
    privileged: true                   # required by DinD
    command: ["dockerd", "--host=unix:///run/dind/docker.sock"]
    environment:
      - DOCKER_TLS_CERTDIR=
    volumes:
      - dind-socket:/run/dind
      - dind-data:/var/lib/docker
    healthcheck:                       # ready once the socket exists
      test: ["CMD", "test", "-S", "/run/dind/docker.sock"]
      interval: 2s
      retries: 30
    mem_limit: 1g
    cpus: 1
    logging:
      driver: json-file
      options: { max-size: 10m, max-file: "3" }

  runner:
    image: data.forgejo.org/forgejo/runner:13.2.1
    depends_on:
      dind: { condition: service_healthy }
    command: ["forgejo-runner", "daemon", "--config", "/config.yml"]
    environment:
      - DOCKER_HOST=unix:///run/dind/docker.sock
    volumes:
      - dind-socket:/run/dind
      - ./config.yml:/config.yml:ro
      - ./data:/data                   # token lives here (mode 600, not in git)
    mem_limit: 128m

volumes:
  dind-socket:
  dind-data:
# config.yml (excerpt)
runner:
  capacity: 1
  timeout: 30m
  fetch_interval: 15s
container:
  privileged: false
  valid_volumes: []
  docker_host: "-"                     # never give jobs a Docker socket
  options: "--memory=768m --cpus=1"
server:
  connections:
    main:
      url: https://git.example.com/
      uuid: <from Forgejo when you create the runner>
      token_url: file:/data/token      # keep the token out of the config file

Use the same URL everywhere

actions/checkout uses the URL the runner used to reach Forgejo. If the runner connects via http://forgejo:3000 (a container name), jobs inside DinD can’t resolve that name, and checkout retries forever.

Use the public HTTPS URL for both the runner and the jobs, and point it at an internal gateway with extra_hosts (runner) and --add-host (jobs, in container.options), so traffic doesn’t go out to the CDN and back.

A global runner or several

Forgejo can register runners at the instance, organization or user level. A runner created under your own account won’t pick up jobs from an organization’s repositories — and there’s no error, the jobs just wait in the queue. For a small team, one global runner per host is simplest.


5. What CI should and shouldn’t do

On a small server, CI exists to catch the silly mistakes, not to replace a build farm.

Good fits:

  • shellcheck, bash -n, php -l, Python syntax checks
  • Config syntax checks, such as nginx -t in a container (self-signed certificates, syntax only)
  • Content and format checks (front matter, whether an image is really an image)
  • Fast unit tests

Poor fits:

  • Large builds that need 0.5–1 GB of memory (front-end bundling, composer install) — they push the host into swap
  • Deployment. Deploying needs credentials, and putting it in a workflow means handing keys to the runner

Two practical rules:

  1. Keep the check script in the repository; the workflow just calls it. Run it locally with the same image and the same command and you get the same result, so a CI failure needs no guessing.
  2. Use a small job image (alpine, for example) and apk add what you need in the first step.
# .forgejo/workflows/check.yml
on:
  pull_request: {}
  push:
    branches: [main]
jobs:
  check:
    runs-on: docker
    container:
      image: data.forgejo.org/oci/node:24-alpine3.23
    steps:
      - shell: sh
        run: apk add -q --no-cache bash git shellcheck python3
      - uses: actions/checkout@v4
      - run: bash .forgejo/check.sh

Scenario 1: run tests on every PR, with a throwaway database

Application tests often need a database. Start a disposable MariaDB with services; the job reaches it by the service name db, and the whole environment is deleted after the run, nowhere near the production database.

# .forgejo/workflows/test.yml
on:
  pull_request: {}
  push:
    branches: [main]
jobs:
  test:
    runs-on: docker
    container:
      image: data.forgejo.org/oci/node:24-alpine3.23
    services:
      db:
        image: mariadb:11.8
        env:
          MARIADB_DATABASE: app_test
          MARIADB_USER: app
          MARIADB_PASSWORD: test-only        # a test password that only exists in this job
          MARIADB_RANDOM_ROOT_PASSWORD: "1"
        options: --memory=256m
    env:
      DB_HOST: db
      DB_DATABASE: app_test
      DB_USERNAME: app
      DB_PASSWORD: test-only
    steps:
      - shell: sh
        run: >-
          apk add -q --no-cache bash git composer
          php84-pdo_mysql php84-mbstring php84-dom php84-xml php84-xmlwriter php84-tokenizer
      - uses: actions/checkout@v4
      - name: Wait for the database
        run: |
          for i in $(seq 30); do
            php -r 'new PDO("mysql:host=db;dbname=app_test", "app", "test-only");' 2>/dev/null && exit 0
            sleep 2
          done
          echo "database not ready after 60 seconds"; exit 1
      - run: composer install --no-interaction --no-progress --prefer-dist
      - run: php artisan migrate --force
      - run: vendor/bin/phpunit

Things to watch:

  • Service containers count against DinD’s limits too. A 768 MB job plus a 256 MB database already exceeds a 1 GB DinD. Lower the job limit or raise DinD’s — measure the peak first.
  • Always wait for the database. A started service container doesn’t mean the database accepts connections; without a wait step, the first test fails at random.
  • A test password can live in the workflow; a real secret can’t. Put external API keys in repository or organization secrets and use ${{ secrets.NAME }}. PRs from forks don’t get secrets, by design.
  • Whether composer install is fine depends on the project. A small project needs a hundred or two MB; for a dependency-heavy one, measure first, and if it’s too heavy, run it only on main and keep PRs to syntax checks. Adjust package names to your Alpine version.

Scenario 2: scheduled checks nobody has to remember

Some things don’t break when code changes — they break when time passes: certificates expire, domains lapse, dependencies gain new vulnerabilities. A schedule trigger makes CI check periodically, and a failure shows up red on the Actions page — more reliable than remembering to look every month.

# .forgejo/workflows/certs.yml
on:
  schedule:
    - cron: "0 1 * * 1"        # UTC — Mondays 09:00 in Taipei
  workflow_dispatch: {}        # can also be run by hand from the web UI
jobs:
  certs:
    runs-on: docker
    container:
      image: data.forgejo.org/oci/node:24-alpine3.23
    steps:
      - shell: sh
        run: apk add -q --no-cache openssl
      - name: Certificates expiring within 14 days
        shell: sh
        run: |
          fail=0
          for host in example.com www.example.com shop.example.com; do
            if echo | openssl s_client -connect "$host:443" -servername "$host" 2>/dev/null \
               | openssl x509 -noout -checkend 1209600 >/dev/null; then
              echo "ok    $host"
            else
              echo "FAIL  $host: expires within 14 days, or unreachable"; fail=1
            fi
          done
          exit $fail
  • cron is in UTC. Write the local time next to it so the next person doesn’t get the conversion wrong.
  • Schedules only take effect from the workflow on the default branch; changing it on another branch won’t run.
  • Always add workflow_dispatch, so you can run it right after a change instead of waiting until next Monday.
  • Check what the outside world sees. This connects to port 443 instead of reading certificate files on the host, so it also catches CDN, reverse proxy and “certificate renewed but not reloaded” problems.
  • The same pattern works for vulnerability checks such as composer audit or npm audit: once a week costs far less than on every PR.

Secrets and variables: what goes where

Workflows live in git where everyone can read them, and some values shouldn’t — or are awkward to — live there. Forgejo Actions has two stores, each available at the repository, organization and user level (Settings → Actions):

Secrets Variables
Usage ${{ secrets.NAME }} ${{ vars.NAME }}
Once saved The value can’t be viewed, only overwritten Visible and editable
In logs Masked as *** Shown as is
PRs from forks Not available Available
Good for Tokens, passwords, webhook URLs, keys Non-secret settings: host lists, versions, thresholds

Every job also gets an automatic token (${{ secrets.GITHUB_TOKEN }}; Forgejo also accepts FORGEJO_TOKEN), which actions/checkout uses to clone. It only works for this repository, can’t reach other private repositories, and expires with the job — nothing to manage.

Scenario A: read another private repository

When shared test tooling, configuration or a submodule lives in another private repository, the automatic token can’t read it, so provide one:

steps:
  - uses: actions/checkout@v4
  - uses: actions/checkout@v4
    with:
      repository: org/tools
      token: ${{ secrets.TOOLS_READ_TOKEN }}
      path: tools
  • Create the token from a dedicated machine account with read access to that repository only, and only the read:repository scope. Don’t use your personal token: it can read all your repositories, and may be able to write.
  • If many repositories need it, make it an organization-level secret so a rotation is a single change.

Scenario B: notify when a scheduled run fails

When the scheduled check above fails, you only find out by opening the web page. Store a chat webhook URL as a secret and send only on failure:

  - name: Notify on failure
    if: failure()
    shell: sh
    env:
      WEBHOOK_URL: ${{ secrets.ALERT_WEBHOOK_URL }}
    run: |
      wget -q -O /dev/null --header 'Content-Type: application/json' \
        --post-data '{"text":"${{ github.repository }}: scheduled check failed"}' "$WEBHOOK_URL"

A webhook URL is a credential: anyone who has it can post to your channel. It belongs in a secret, not a variable.

Scenario C: settings that aren’t secret but change often

The list of domains to check grows and shrinks with your sites, and editing the workflow plus a PR every time is overkill. Use variables:

  - name: Certificates expiring soon
    shell: sh
    env:
      HOSTS: ${{ vars.CERT_HOSTS }}             # e.g. "example.com www.example.com"
      DAYS: ${{ vars.CERT_DAYS || '14' }}       # 14 days when unset
    run: |
      for host in $HOSTS; do
        echo | openssl s_client -connect "$host:443" -servername "$host" 2>/dev/null \
          | openssl x509 -noout -checkend $((DAYS * 86400)) >/dev/null \
          || { echo "FAIL  $host"; fail=1; }
      done
      exit ${fail:-0}

The price of variables is no history: git can’t tell you who changed what, or when. Keep them to values that don’t need review; anything that changes behavior or deserves discussion still belongs in the repository, through a PR.

Rules of use

  1. Give it only to the step that needs it. Put a secret in the step’s env, not the job’s or workflow’s. Other steps — especially third-party actions — never see it, so they can’t leak it.
  2. Masking only recognizes the exact value. Base64, URL encoding or printing it in pieces all end up in the log in clear text. Never echo a secret, and don’t run commands that use one under set -x.
  3. Keep secrets out of artifacts and caches. If a generated config file contains a secret, don’t upload it.
  4. Least privilege, with an expiry date. Read-only when read is enough; give tokens an expiry and rotate before it; when someone leaves the team, rotate the tokens they created.
  5. Document the name and purpose, never the value. For example, in AGENTS.md: “TOOLS_READ_TOKEN: organization level, read-only token of machine account ci-bot, reads org/tools, expires 2027-06.” Then the next person knows what it’s for, who to ask when it breaks and when it’s due for rotation.
  6. Deployment keys don’t go into CI, for the reason in section 5: put them there and anyone who can edit a workflow can deploy.

6. Principles that keep things from breaking

This is the checklist I go through before touching anything:

  1. Look at the data before changing anything. Every conclusion should point to a number and the record it’s compared against. If there isn’t enough data, collect it first — don’t add resources on a hunch.

  2. Confirm, back up and know how to roll back before touching production. Restarting the database, reloading the web server and editing live site files all count. Reload only after a check such as nginx -t passes, and don’t restart.

  3. Secrets never go into git — or into chats, issues or logs. Put tokens in a mode-600 file and read them with token_url: file:...; when a person has to type one in, use read -s.

  4. Verify that a config change actually took effect. With a single-file bind mount, the editor replaces the file on save and the running container still sees the old one — use --force-recreate.

  5. Pin versions. Use full version tags for images, never latest; read the release notes for config changes before a major upgrade.

  6. Every cleanup command needs a keep condition. docker system prune -a deletes every image not currently in use, including ones you loaded by hand and can’t pull back. Label those images and exclude them:

    docker build --label ci.keep=1 -t my-ci-image .
    docker system prune -af --filter until=168h --filter label!=ci.keep
  7. Cap the logs. Docker’s json-file logs are unbounded by default; set max-size and max-file.

  8. Poll at a sensible rate. A runner that polls every 5 seconds makes 17,000 requests a day. At 15 seconds a job starts at most 15 seconds later, which a small team won’t notice.

  9. Commit, push and merge only when asked. Especially for AI agents: “done” and “pushed” are separate confirmations.


7. Common pitfalls

Symptom Cause Fix
A job hangs at checkout, retrying The runner reaches Forgejo by container name, which jobs in DinD can’t resolve Use the public URL for both, mapped internally with extra_hosts / --add-host
The runner can’t reach Docker on first start and restarts once The runner starts before DinD Give DinD a healthcheck; runner depends_on: condition: service_healthy
runner registration token not found Newer runners dropped register; the token shown when creating a runner isn’t the old registration token Use server.connections (UUID + token file)
Jobs in one repository wait forever The runner was created at user level and ignores organization repos Create a global, instance-level runner
CI suddenly fails with No such image / pull access denied A cleanup deleted a hand-loaded image; CI won’t rebuild it Load it again, and always clean up with the label filter
A PR is stuck at “checking” forever Two branches each re-encoded the same set of files from the same starting point; thousands of files conflict and the conflict list overflows a database column Once you’ve confirmed one side supersedes the other, record the merge with git merge -s ours, then open the PR

The last one deserves a sentence of its own: do bulk encoding conversions, reformatting or directory moves once, on one branch, and merge back to the mainline right away. Do it on two branches and Git can’t merge them for you, because every line differs.


Closing

The process looks like a lot of steps, but each one answers the same question: what will the next person (or the next AI agent) need to know?

  • Why it was done → the issue
  • How it was verified → the PR
  • Which mistakes must not recur → CI
  • Which pitfalls we already hit → AGENTS.md
  • What the change actually did → the CHANGELOG

A small team can’t afford to fall into the same hole twice. Let the process remember the rules, and people can spend their attention where real judgment is needed.