The host

One VM, one user, three containers, and a directory of plain files.

A Yard host is one Linux VM. This chapter walks through what is on it, and why each piece is there. You do not need to know any of it to deploy an app. You need it on the day you SSH in to look around, and that day goes much better when nothing on the machine is a mystery.

One VM#

Yard targets Ubuntu 26.04 LTS. Any provider works. Two cores and 4 GB of memory hold a surprising number of small apps, because a small web app at rest uses almost nothing.

One VM is enough, because most small apps do not need three, and every extra machine adds a network, a failure mode, and a bill. One VM can be read top to bottom by one person. When it gets full, you move to a bigger one. That is a real limit, and Yard says so plainly: there is no failover, and when the VM is down, every app on it is down.

The yard user#

The install script creates a user called yard, and everything that matters runs as that user: Podman, every container, and Yard itself. Root sets up the machine once and then steps back.

This is rootless Podman. Each container runs in the yard user’s namespace, so “root” inside a container is the unprivileged yard user outside it. A process that escapes its container lands in an account that owns nothing but Yard’s own files. That is a quiet, solid kind of safety, and it costs nothing at run time.

Three small systemd pieces keep the user alive:

  • Linger keeps the yard user’s services running with nobody logged in.

  • podman.socket gives Yard and the deploy containers an API to talk to Podman.

  • podman-restart.service starts the containers again after a reboot.

When you SSH in and want to look at containers, become that user first:

sudo -u yard podman ps

A plain podman ps as root shows an empty list, because root has its own, separate Podman.

The containers#

On a fresh host, sudo -u yard podman ps shows two containers, and the first app makes three:

  • yard-app-1 is Yard. It answers the API, hears the webhooks, runs the checks, and starts the deploys.

  • yard-caddy-1 is Caddy. It owns port 443 and the certificates.

  • notes-app-1 is your app.

After a deploy, podman ps -a also lists a stopped deploy-notes. That is the container that did the build and the swap. It stays as the record of the last deploy, until the next deploy replaces it.

Yard and Caddy are themselves a Compose project named yard, described by ops/compose.yml in the Yard repo. That is why their names follow the same <project>-<service>-1 rule as every app. It is also why Yard can deploy itself: Yard is just the app named yard.

One public port#

From the internet, a Yard host has exactly one door: port 443, for TCP and UDP. SSH is yours to keep or close. Port 80 can stay closed, because Caddy gets its certificates over 443.

There is one wrinkle. A rootless user cannot listen on a port below 1024. So Caddy’s container is published on port 8443, and a small nftables rule on the host redirects public 443 to 8443:

table inet yard {
  chain prerouting {
    type nat hook prerouting priority dstnat; policy accept;
    tcp dport 443 redirect to :8443
    udp dport 443 redirect to :8443
  }
}

A systemd unit, yard-proxy.service, loads that rule at every boot. When ufw is active, the install script also allows 8443 for TCP and UDP. The firewall sees each packet after the redirect, so a rule for 443 alone would not cover it.

Inside its container, Caddy still listens on 443. That detail matters for HTTP/3. Caddy tells browsers that HTTP/3 is on port 443, and 443 is the port they reach.

On the host, Yard is published only on 127.0.0.1:8000. From the internet, the only way to Yard is through Caddy, on the site file that the install script writes for Yard’s own domain. On the yard network, every container can reach Yard at yard:8000, and the API still asks each of them for a token.

Where the state lives#

Yard has no database. Its state is a tree of plain files in /srv/yard:

/srv/yard/
  apps/
    notes/
      app            the record: the source, the branch, the trigger, when it was made
      env            the settings, mode 600
      data/          the container's /data
      repo/          Yard's clone of the repo; each deploy resets it
      Containerfile  the file Yard wrote, when the app runs a yard: template
      compose/       for a compose URL app: the copy that runs, and the one before it
    yard/
      app, env, repo/    Yard's own record, settings, and clone
      data/github/       the forge's state.json and key.pem, mode 600
      data/origin/       the same, for Cursor Origin
      caddy/config/sites/<name>.caddy   one site file per app
      caddy/data/        Caddy's certificates
      caddy/run/         Caddy's admin socket
  tokens/            one file per API token, holding its hash

An app record is a few lines of text:

repo https://github.com/you/notes
ref main
trigger push
created 2026-11-06T15:00:00Z

That is the whole record. One fact is not in a file: what each app runs right now. The deploy container holds it in its labels. Deploys explains why the record lives there.

Every write goes to a temporary file first and is then renamed into place, so a crash never leaves half a file. Yard reads the files again each time it needs them. There is no cache to go stale and no schema to migrate.

Backing up a host is copying /srv/yard. A running database can be caught in the middle of a write, so dump it first. Self-host a service shows a pg_dump.

The journal#

The install script makes the system journal persistent and caps it at 2 GB. Then it tells the yard user’s Podman to send two things there:

  • Container output, so a log outlives the container that wrote it.

  • Podman’s events: a container was created, started, died, or removed.

Yard adds its own events to the same journal. So the history of the host lives in one place, and yard logs and yard events read it from your laptop. Logs and events explains the three streams.

What the install script does#

The script is plain sh, it asks no questions, and a second run repairs a half-finished install. In order, it:

  1. Installs Podman, nftables, git, curl, and a few helpers from Ubuntu’s packages. It refuses a Podman older than 5.

  2. Creates the yard user, its subordinate IDs, and linger.

  3. Creates /srv/yard, owned by yard.

  4. Writes the nftables redirect from 443 to 8443, and opens 8443 in ufw when it is active.

  5. Makes the journal persistent and points Podman’s logs and events at it.

  6. Starts podman.socket for the yard user.

  7. Clones Yard into /srv/yard/apps/yard/repo, builds it, and starts yard-app-1 and yard-caddy-1, with the same kind of deploy container that every later deploy uses.

  8. Prints the admin token that Yard made on its first start.

  9. With --domain, writes the site file for Yard’s own domain and checks that https://<domain>/health answers.

  10. Points the yard app at the repo it came from, so a later upgrade is one command.

Set up a host runs it with you, step by step.

By the way#

  • Why Podman 5 or newer? Podman 4’s Docker-compatible API dropped container labels from its list answer. Compose then believed every container had changed and recreated all of them on every deploy, Caddy included.

  • Yard’s container mounts only what it needs: /srv/yard, the directory that holds Podman’s socket, the journal read-only, and the journald socket for its own events.