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:
- Backups: repositories, the database and configuration need off-site copies — and you need to have restored from them at least once.
- Upgrades: pin the major version and read the release notes before moving; never use
latest. - 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 #12andRefs #12. An issue that tracks follow-ups (“check the numbers in a week”) must be referenced withRefs, 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:
- 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.
- Write the pitfall down the moment you hit it. Don’t wait to “tidy up later” — later never comes.
- 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_limitandcpus; however hard CI runs, production keeps the rest. On a small host, setcapacityto 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 -tin 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:
- 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.
- Use a small job image (alpine, for example) and
apk addwhat 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 installis 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 onmainand 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
cronis 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 auditornpm 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:repositoryscope. 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
- 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. - Masking only recognizes the exact value. Base64, URL encoding or printing it in pieces all end up in the log in clear text. Never
echoa secret, and don’t run commands that use one underset -x. - Keep secrets out of artifacts and caches. If a generated config file contains a secret, don’t upload it.
- 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.
- Document the name and purpose, never the value. For example, in AGENTS.md: “
TOOLS_READ_TOKEN: organization level, read-only token of machine accountci-bot, readsorg/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. - 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:
-
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.
-
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 -tpasses, and don’t restart. -
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, useread -s. -
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. -
Pin versions. Use full version tags for images, never
latest; read the release notes for config changes before a major upgrade. -
Every cleanup command needs a keep condition.
docker system prune -adeletes 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 -
Cap the logs. Docker’s
json-filelogs are unbounded by default; setmax-sizeandmax-file. -
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.
-
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.