Files
familienarchiv/docs/adr/011-single-tenant-gitea-runner.md
Marcel 6a6a1c4353 docs(adr): ADR-011 single-tenant Gitea runner with on-disk env-files
Records the operational assumption that nightly.yml and release.yml
bake in: the self-hosted runner is single-tenant, so writing secrets
to .env.staging / .env.production on disk and removing them via an
`if: always()` cleanup step is acceptable for v1.

Documents the three migration triggers (second repo on the runner,
untrusted PR execution, move to shared infrastructure) and the
one-step migration path (--env-file <(printf '%s' "$SECRET_BLOB"))
so the next operator does not silently break the trust assumption.

The in-comment notes at the top of both workflow files already point
at this ADR's content; this commit records the decision in the durable
location the doc-currency table demands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:16:20 +02:00

5.4 KiB

ADR-011: Single-tenant Gitea runner with secrets-on-disk env-files

Status

Accepted

Context

The deploy workflows (.gitea/workflows/nightly.yml, release.yml) execute on a self-hosted Gitea Actions runner. The runner has Docker-out-of-Docker access (the host's Docker socket is mounted into the runner), so docker compose build produces images on the host daemon and docker compose up consumes them directly — no registry hop.

Two workflow steps shape the security model:

  1. "Write env file" — the workflow writes every required secret to .env.staging or .env.production on the runner's filesystem so that docker compose --env-file can consume them. The file lives on disk for the duration of the workflow.
  2. "Cleanup env file" — the matching if: always() step deletes the env file after the workflow ends, regardless of success.

This shape only works under one operational assumption: the runner is single-tenant. The runner is owned by the same operator who owns the secrets, no other repositories run jobs on the same runner, and no untrusted code is executed (no public fork PRs trigger workflows). If any of those held, the env-file-on-disk approach would be a credential exposure path — a sibling job could read .env.production, or a malicious PR could exfiltrate the secrets via a step.

The alternative — docker compose --env-file <(printf "..." ) (bash process substitution) — is technically supported and would keep secrets out of the on-disk filesystem. It is more secure under a multi-tenant runner but requires bash 4+ and is brittle inside YAML (the printf step would need to escape every secret value containing newlines, equals signs, or quotes).

Decision

The runner is treated as single-tenant for the lifetime of the v1 deployment. The workflows write env-files to disk under that assumption and rely on the if: always() cleanup step to remove them. The operational assumption is documented in-comment at the top of both workflow files (nightly.yml, release.yml) so the next operator who considers adding a second repo or accepting public PRs has the trigger surfaced in front of them.

Concretely:

  • The Gitea runner only runs jobs for marcel/familienarchiv.
  • No public fork PRs trigger the workflows (Gitea defaults to requiring an explicit approval on first-time contributor PRs for the actions to run).
  • Secrets are stored in Gitea repository secrets and injected via ${{ secrets.* }}. They land in the env-file at workflow start and are removed at workflow end.

Migration trigger

Switch to the multi-tenant-safe pattern when any of the following becomes true:

  • A second repository starts using the same runner.
  • A workflow accepts contributions that can run untrusted code (public PRs without manual approval).
  • The runner is moved off the operator's controlled host onto shared infrastructure.

The migration path is one-step per workflow: replace the "Write env file" step with --env-file <(printf '%s' "${{ secrets.STAGING_ENV_BLOB }}") and store the full env-file as a single Gitea secret. The cleanup step is then unnecessary because the env-file never touches disk.

Alternatives Considered

Alternative Why rejected (for now)
--env-file <(printf "...") via bash process substitution More secure under multi-tenant. Brittle for multi-line / quoted secret values; harder to debug ("env file not found" with no diff to inspect). Justified once the trigger above fires.
Docker secrets (docker secret create + compose secrets:) Designed for Swarm; outside of Swarm, compose secrets read from files anyway, so the on-disk surface is the same. Adds complexity without changing the threat model.
External secret manager (Vault, AWS Secrets Manager) Adds a third-party dependency to the deploy path. For a family-archive deployment with one operator and one VPS, the cost outweighs the benefit at this scale.
GitHub-hosted ephemeral runners Would require uploading the prod-deploy artifacts to a registry first, then a deploy step on the VPS connecting back. Inverts the current Docker-out-of-Docker simplicity for marginal security gain. The single-tenant self-hosted runner is ephemeral in practice — the secrets are written to a directory the runner controls, then deleted.

Consequences

  • The runner host's filesystem is in the secret-trust boundary. The host is hardened per docs/DEPLOYMENT.md (ufw, fail2ban, Tailscale-only SSH).
  • An operator who later adds a second repo to the runner without revisiting the workflows would silently break the trust assumption. The in-file comments at the top of nightly.yml and release.yml are the breadcrumb that surfaces the assumption at change time.
  • The if: always() cleanup step is load-bearing: removing it (e.g. during a future workflow refactor) leaves credentials on disk between runs. Treat it as a permanent invariant.
  • Workflow debuggability stays high: an operator who needs to know what env-file the deploy ran with can SSH onto the host while a workflow is in flight and cat .env.staging — useful for first-deploy diagnostics.

Future Direction

When the trigger fires, migrate both workflows in a single PR: replace the "Write env file" step with a single --env-file <(printf '%s' …) invocation, drop the cleanup step, and consolidate the per-secret Gitea entries into a single multi-line STAGING_ENV_BLOB / PROD_ENV_BLOB secret. Single commit, both workflows, no application change.