Deploys

When Yard deploys, how it deploys, and how it remembers.

A deploy is the moment the code you pushed becomes the code that runs. Yard’s whole job circles around it, so this chapter goes slowly. It answers three questions in order: when does Yard deploy, what happens during a deploy, and how does Yard remember what it did?

The check#

Yard never deploys because someone told it a commit. It deploys because it looked and saw that something moved. That looking is called a check, and it is one question for one app: is the newest version of the source the one that runs?

First, the check reads the newest version of the source. Yard calls it the tip:

  • A repo: git ls-remote for the app’s branch, with a short-lived token from the forge.

  • An image: a HEAD request to the registry for the tag’s manifest, which answers with the digest.

  • A compose URL: a fetch of the file over HTTPS, hashed with SHA-256.

Then it compares the tip with the plan that runs now, from the labels of the app’s last deploy container. The plan has moved in three cases:

  • The revision differs. A new commit, a new digest, or new bytes in the file.

  • An explicit runs changed. You ran yard app set --runs Containerfile, so the next check deploys once, even on the same commit.

  • The template stamp changed. The app runs a yard:<template>, and this Yard’s templates are newer than the ones it was built with. A Yard release that improves a template deploys each such app once.

If the plan moved and the app’s trigger is push, the check deploys. (The trigger explains push and manual.) If the trigger is manual, the check only records the tip, and yard status shows the app as behind: 9c2c7e1 → a1b2c3d.

A failed read never deploys. The app keeps running, and yard status --full shows a source <name> warning until a check works again.

What asks for a check#

One worker inside Yard runs the checks. Two things wake it.

A webhook. When you push, the forge calls /hooks/github or /hooks/origin. The handler learns which repo moved, asks the worker to check the push apps on that repo, and answers at once. The body never names the commit Yard deploys. The commit always comes from git ls-remote. Forges and webhooks explains why.

The timer. Every five minutes, the worker checks every app. For a repo app, this catches a webhook that the forge never sent. For an image or a compose URL, nobody sends a webhook, so the timer is the only trigger. YARD_CHECK_SECONDS in Yard’s env changes the five minutes.

Each app has at most one check at a time. A request that arrives during a check is remembered, and it runs once after the check ends. So a burst of ten pushes makes one more check, not ten. Different apps check side by side, so a slow build of one app never holds up another.

Failed is final, for that version#

A deploy that fails or reverts is not tried again for the same version. The labels of the last deploy container name what it tried, so this holds after a Yard restart too. Without this rule, a broken commit would build, fail, and build again every five minutes, forever.

The next commit, digest, or file gets a fresh try. And yard app deploy always runs, whatever failed before, because you asked for it by hand.

The trigger#

Each app has a trigger, push or manual, and push is the default.

  • push deploys each new version that a check finds.

  • manual only watches. Nothing deploys until you run yard app deploy.

Manual is right for software you do not control: an image whose latest could jump a major version, or Yard itself on a host where you want to read the changelog first. yard app set --trigger manual also pauses a push app while you investigate something.

The deploy container#

Now the deploy itself. Yard does the quick parts in its own process, and hands the heavy part to a container called deploy-<name>.

Yard prepares the plan. For a repo, it syncs the clone at /srv/yard/apps/<name>/repo to the tip, and decides what runs (What an app runs). The forge token reaches git through an environment variable and a credential helper, so it never lands in the clone’s .git/config or on a command line. For an image, Yard asks the registry for the digest. For a URL, it fetches the file and keeps a copy. Each step writes one line to the deploy log:

== sync https://github.com/you/notes main
== runs yard:python-uv (containerfile; stario: pyproject.toml declares stario)

Yard starts the container. In effect, Yard runs this:

podman rm deploy-notes
podman run -d --name deploy-notes --network yard \
  -v <podman.sock>:/run/podman/podman.sock -v /srv/yard:/srv/yard \
  -e YARD_LOG=… -e COMPOSE_PROJECT_NAME=notes -e COMPOSE_FILE=… \
  -e IMAGE=localhost/notes:9c2c7e1a2b3c-3f9a1c2e -e SHA=… -e CHECKOUT=… \
  -e DATA_DIR=… -e APP_ENV=… -e PROJECT_NAME=notes -e PREVIOUS_IMAGE=… \
  --label yard.revision=… --label yard.image=… --label yard.before.revision=… … \
  localhost/yard-apply up
podman logs -f deploy-notes
podman wait deploy-notes

The image, localhost/yard-apply, is small: Alpine, podman-remote, docker-compose, and one shell script. It mounts the yard user’s Podman socket, so it drives the same Podman that runs everything else. Everything the script knows comes from those environment variables. YARD_LOG holds the lines that Yard wrote before the container started, and the script prints them first. So the container’s own log is the whole deploy, from the sync to the last line.

The container runs detached. Yard follows its log and waits for its exit code, but the deploy does not depend on Yard staying alive. That is what lets a deploy of Yard replace Yard. Inside Yard tells that story.

Yard keeps the build out of its own process for three reasons. A build can be slow, loud, and crashy, and none of that should touch the program that answers your commands. The deploy gets its own log, kept after it ends. And everything it needs is written down in its environment, where podman inspect can read it.

Inside the deploy container#

The script, apply.sh, is about a hundred lines of sh. It does four things.

It checks the contract. For a compose file of yours, it checks that the service app is on the network yard, and stops if not. It warns when no service reads ${APP_ENV} or mounts ${DATA_DIR}. Yard’s own compose files skip the check, because they keep the contract by design.

It builds the image, unless the image already exists:

== build localhost/notes:9c2c7e1a2b3c-3f9a1c2e
built 2026-11-06T14:02:11Z

A commit that already has an image prints == reuse instead. That is why a new setting or a deploy by hand takes seconds. An image app and a compose URL app never build.

It starts the new version:

== start localhost/notes:9c2c7e1a2b3c-3f9a1c2e

That is docker-compose up -d --wait --wait-timeout 120 --remove-orphans. Compose replaces the containers whose image or settings changed, and leaves the rest alone. --wait waits until every service is running, and healthy when it has a healthcheck, for up to 120 seconds.

It goes back when the new version fails. When up fails, the script runs up again with the previous image, or the previous compose file for a URL app, and exits with code 3:

== unhealthy: up failed
== revert localhost/notes:5e4d3c2b1a09-3f9a1c2e
== reverted localhost/notes:5e4d3c2b1a09-3f9a1c2e

A first deploy has nothing to go back to, so it ends as failed.

The healthcheck is the gate#

Compose does not run the new version next to the old one. It stops the old container and starts the new one under the same name, and Caddy sends traffic to that name. So the new version serves visitors as soon as it listens. The healthcheck does not keep a bad version away from them. It makes the decision after the swap: within two minutes, is the new version good, or does the old one come back?

With a healthcheck, a broken app is live for at most those two minutes, and then the previous image is back. Without one, “healthy” means “running”, and a container that runs but answers nothing stays live until you push a fix. A build that fails is a different case: it never starts a container, so the old version keeps running either way.

The Containerfiles that Yard writes have no healthcheck, because Yard cannot know where your app answers. Healthchecks shows the one line to add, and the interval that decides how fast a deploy goes live.

The record#

When the container stops, the deploy is done, and the container stays. That stopped container is the only record of the deploy. Yard keeps no database and writes no deploy file.

Its exit code is the outcome:

  • 0 is ok: the new version runs.

  • 3 is reverted: the new version never became healthy, and the previous one runs.

  • Any other code is failed: something else went wrong, and the log says what.

Its labels are the plan. Yard writes them all when it creates the container, before anything runs:

  • The new plan: yard.revision, yard.image, yard.runs, yard.kind, yard.reason, and yard.template.

  • The plan that ran before, with the same keys under yard.before..

  • Who and why: yard.cause (a push, a check, or by hand), yard.actor, and yard.error for a deploy that failed early.

From these two facts, Yard knows what each app runs. On exit 0, the new plan runs. On any other end, the before. plan runs. The check reads exactly this, and so does yard status.

Why put the record on the container? Because nothing is written after the deploy. A Yard that restarts in the middle of a deploy, even a Yard that the deploy itself replaces, finds the whole record waiting when it comes back. There is no moment where the record says one thing and the host another.

A deploy that fails before the container would normally start, such as a sync error, a registry error, or a refusal from the detector, still leaves a record. Yard starts the same container with the command fail. It prints the lines so far and exits 1. So every deploy leaves exactly one deploy-<name> container, and yard app logs --deploy always has something to show.

yard events turns Podman’s events for these containers into deploy.start and deploy.end rows. So the deploy history of an app is in the journal too, long after each container has been replaced.

One deploy at a time#

A running deploy-<name> is also the lock. Yard removes the last deploy container with a plain podman rm, and Podman refuses to remove a running one. So a second deploy cannot start on top of the first, even after a Yard restart.

A check, a webhook, or a yard app deploy that finds a deploy running does not fight it. It follows that deploy’s log to the end. Then it asks for one more check, because the running deploy may have started on an older commit. The newest commit still deploys, once.

Deploy by hand#

yard app deploy

This deploys what the source says now: the tip of the branch, the digest the tag points to, or the file as it is at the URL. It runs even when nothing changed, which is how you apply a new setting, retry after a fix outside the repo, or deploy a manual app. The log streams as it happens. Ctrl-C stops the stream, and the deploy goes on.

Rolling back#

There is no rollback command, and that is on purpose. Yard runs what the source says now. If Yard could run something else, a five-minute check would happily put the source back. So to go back, change the source:

  • A repo: git revert the bad commit and push. The revert is a new commit, and the build cache makes it quick.

  • An image: set an older tag with yard app set --image, then deploy.

  • A compose URL: point --compose-url at the raw file of an older commit, then deploy.

The history then says what happened, in the same place as everything else.

By the way#

  • Yard writes no commit status back to the forge. The record lives in yard app status, yard app logs --deploy, and the journal.

  • A deploy does not touch Caddy. The site file names the container, and the container keeps its name. Caddy and HTTPS explains the rest.