How it works

One push, one apply container, one running app.

Yard is a small program between three programs that already work. This page walks through what each one owns and what one git push sets in motion.

The three programs and the one in between#

Git owns the code. Each app is a bare repository in /srv/yard/repos, and a reference-transaction hook inside it reports every push.

Podman owns the containers. Yard talks to it through the rootless socket; every build, start, and probe is a podman call.

Caddy owns HTTPS. It terminates TLS and proxies each domain to a slot on the yard network.

Yard owns the rest. A SQLite file records repos, slots, tokens, and deploys. Env files hold the secrets. One JSON document is the whole Caddy configuration. One apply image runs every deploy.

Where files live#

Yard keeps its files in /srv/yard:

  • repos/ — the bare git repositories, one per repo.

  • checkouts/ — one extracted revision per deploy, the build context.

  • data/ — per-slot data; the container sees it at /data.

  • data/yard/ — Yard's own SQLite and the admin token.

  • env/ — the slot env files, mode 600.

  • run/ — the Caddy admin socket.

A push, step by step#

git push lands in the bare repo, and the reference-transaction hook posts the repo name, the ref, and the new SHA to POST /api/v1/hooks/push. The hook only answers localhost — it is called by git on the same machine.

Yard looks up the slots that watch that ref. For each one it extracts the revision with git archive into checkouts/<repo>/<sha>. Then it checks the compose contract: a repo with its own compose.yml must put the web service on the yard network with the slot name as an alias.

Then Yard starts one apply container:

podman run --replace --name yard-apply-<slot> --network yard \
  -v <podman.sock>:/run/podman/podman.sock -v /srv/yard:/srv/yard \
  -e COMPOSE_PROJECT_NAME=<slot> -e COMPOSE_FILE=... -e PREVIOUS=... \
  -e APP_ENV=... -e CHECKOUT=... -e DATA_DIR=... -e IMAGE=... -e SHA=... -e SLOT_NAME=... \
  localhost/yard-apply up

Inside, apply.sh runs the deploy: compose build, compose up -d --wait --no-build, then an HTTP probe on http://<slot>:8000/ for up to 30 seconds. If any step fails it starts PREVIOUS, the image that was live. Every step prints a == line, so the log reads as the whole story:

== build localhost/heldso:9c2c7e1
built 2026-10-02T14:02:11Z
== start localhost/heldso:9c2c7e1
== wait http://heldso:8000/
== ready localhost/heldso:9c2c7e1

One container for the whole deploy is the point. podman logs yard-apply-<slot> is one log for build, start, probe, and revert. And the apply container is a separate process from Yard, so it survives Yard's own restart — which is what makes the self-upgrade below possible.

The deploy ends as a row in SQLite: status, SHA, image, timing, and the full log. yard deploys <name> <id> prints that log verbatim.

Names and labels#

The apply container is yard-apply-<slot>, or yard-apply-<slot>-job for a job run. Compose's project name is the slot name, so the app containers and their logs all carry the slot name. Yard adds no labels of its own — the compose labels like com.docker.compose.service are already there. Names are enough because a slot is one app and one project.

The env#

The apply command gets ten names: COMPOSE_PROJECT_NAME (the slot), COMPOSE_FILE (the project files), PREVIOUS (the image to revert to), APP_ENV (the slot env file), and CHECKOUT (the build context). The rest are DATA_DIR (the slot's data), IMAGE and SHA (what is being built), SLOT_NAME (the network alias), and DOCKERFILE when the repo has no compose file.

Inside the app container, Yard sets four names: SLOT_NAME, SLOT_DATA_DIR, SHA, PORT. The slot's own values come from the slot env file via env_file:. It is a file — /srv/yard/env/<name>.env, mode 600 — because secrets belong on disk next to the data, not in Yard's SQLite.

Caddy#

Yard keeps one JSON document as the whole Caddy configuration: one route per slot domain, proxying to <slot>:8000 on the yard network. On every slot change Yard posts a fresh document to Caddy's admin socket at /srv/yard/run/caddy.sock. Caddy writes it to its autosave file and --resume loads that on the next start. ops/caddy.json from the repo is only the first config, an empty document with the admin socket open. The host forwards 443 to Caddy's 8443 with nft, and Caddy handles TLS and certificates itself.

Jobs and the trigger#

A job slot is a slot whose TRIGGER field is not empty — push, manual, or every <duration>. yard run <name> is the verb for all of them: it takes the slot's lock and checks no container of that slot is running — a second run gets nightly is already running. It then runs the apply container with the job command for a job, or start for a down service, and returns the output. A push trigger runs that same path right after its own build. An every trigger runs a ticker per slot, and the ticker reads the last run from the yard-apply-<slot>-job container's created time — Yard keeps no schedule state, so a restart does not fire the job and a manual yard run moves the schedule. A trigger edit wakes the sleeper early; the wake reschedules, it is not a second trigger.

The self-upgrade#

The yard slot is a slot like any other, so upgrading Yard is a deploy. Its apply container runs detached, because the deploy replaces the very process that started it. The new process boots, and _claim_yard_deploy closes the row the old process left running. The row is ok when the SHA matches, failed — with the apply log — when the apply container rolled back to the previous image.

What Yard does on every boot#

Each boot, in order: ensure the yard repo row and slot exist, claim the deploy that produced this process, and mark interrupted deploys. It also writes the git hooks and the partial-clone flag on every repo, ensures the yard network, and rebuilds the apply image. Then it waits for the Caddy socket, pushes the Caddy document, and starts the job tickers.

Security in one paragraph#

The admin token is a file at /srv/yard/data/yard/admin.token, mode 600, and every API call needs it. The push hook answers only localhost, so pushing never exposes a public endpoint. Slot env files are mode 600 and stay out of SQLite. The apply container mounts the Podman socket and /srv/yard — it needs both to do its work, and it is gone when the deploy ends.