Guide

Push a repo. Yard builds the image and serves the app.

I wanted one VM that runs several apps, where git push is the whole deploy. No pipeline file, no build service in the middle, no dashboard. Yard is what I use for that.

The machine already has everything it needs: git for the repos, Podman for the containers, Caddy for HTTPS. Yard is the small program between them. It keeps a bare repo per app, builds an image on each push, and tells Caddy where each domain goes.

On your laptop there is one command, yard. It talks to the host over HTTP. The first thing it prints is a table of the machine. It shows what is up, how long it has been up, and the revision it runs.

Yard is built for Stario apps, and the defaults assume one: a Containerfile at the root, a process on port 8000, settings from the environment. Nothing in it reads Python, though. Any app that fits in a container can live on the same host; Any app in a container at the end says what it needs.

The command is yard. The Python package is staryard, because yard is already taken on PyPI. Python 3.14 is required. Source: github.com/Bobowski/yard.

Install on the machine#

The host needs git and Podman and nothing else. Run the install script on the VM, as root, from the Yard repo:

./install.sh --domain yard.example.com

The script prepares the host — a yard user, the Podman socket, the firewall rule that forwards 443 to Caddy's 8443. Then it clones the bare Yard repo, builds the apply image, and runs the first deploy by hand. When Yard answers it prints an admin token and runs yard doctor on the host so you can see every check pass. A second run of the same script repairs an existing install.

Install the command#

uv tool install staryard
yard login --url https://yard.example.com --token <token>

yard login writes the host and the token for this user. A later command can still pass --url and --token.

One app#

A slot is one app. The container has the same name.

The repo needs a Containerfile or a Dockerfile, at the root or in ops/.

yard init --domain app.example.com
git push -u yard main

yard init creates the git repo, the Yard repo, and the slot. It does not push. The next push runs a reference-transaction hook in the bare repo, which posts to Yard, which starts one apply container. The hook relays Yard's answer, so the push prints the deploy as it happens — the summary line, then the apply log:

remote: slot heldso: ok localhost/heldso:9c2c7e1
remote: == build localhost/heldso:9c2c7e1
remote: == start localhost/heldso:9c2c7e1
remote: == wait http://heldso:8000/
remote: == ready localhost/heldso:9c2c7e1

Yard removes the slot when that branch is merged or deleted.

--name sets the repo name and the slot name. --repo and --slot set them apart. --ref selects the branch. The default ref is main. --dir selects a directory inside the repo.

To start from a GitHub address, pass it to the host. The laptop does not need a checkout.

yard init --remote https://github.com/org/heldso --domain heldso.example.com

Yard clones that address and follows origin/main. The first deploy builds that revision. --ref selects another branch.

The slot file#

Each slot is a file of fields and env values. yard slot show <name> prints it:

# slot heldso
# image localhost/heldso:9c2c7e1
# Yard sets SLOT_NAME SLOT_DATA_DIR SHA PORT in the container.
# The file is /srv/yard/env/heldso.env on the host.
# TRIGGER: empty is a service that stays up; push, manual, or every 5m make a job.
REPO=heldso
REF=main
DIR=
DOMAIN=heldso.example.com
TRIGGER=
 
# env. The container receives these lines.
API_TOKEN=secret

REF is the branch the slot follows. DIR is a subdirectory of the repo. DOMAIN is the public host name. TRIGGER says when the slot runs; empty is a service. The lines under # env are what the container receives.

The file lives on the host at /srv/yard/env/<name>.env, mode 600, so secrets stay out of Yard's SQLite. yard slot edit <name> opens it in $EDITOR; --set KEY VALUE sets one key and an empty value removes it. An edit of REF, DIR, or an env value runs a deploy. A domain or trigger edit does not.

Your own compose file#

A repo with one Containerfile needs no compose file; Yard supplies one. Write your own when the app wants more than one container — a worker next to the web service, a sidecar, a database. A compose.yml (or compose.yaml, at the root or in ops/) runs as it is. The contract has four parts.

The service Yard builds uses image: ${IMAGE} and build.context: ${CHECKOUT}. Yard sets both names before compose runs.

The web service joins network yard with alias ${SLOT_NAME}. The health probe dials http://<slot>:8000/, so the alias is a hard rule. A compose file without it fails the deploy:

compose: no service on network yard with alias heldso; the health probe dials http://heldso:8000/

env_file: ${APP_ENV} attaches the slot env. Without it Yard warns warning: compose: no service attaches env_file ${APP_ENV}; the slot env is not passed.

${DATA_DIR}:/data mounts the slot's data. Without it: warning: compose: no service mounts ${DATA_DIR}; the slot has no /data. Yard also sets SLOT_NAME, SLOT_DATA_DIR, SHA, and PORT in the container.

ops/apply/default.compose.yml in the Yard repo is the file Yard uses when you have none. Read it to see the contract satisfied in twenty lines.

When a slot runs#

A service stays up. A job runs and exits. The difference is one field, TRIGGER, and it has four values.

An empty TRIGGER is a service: a push builds the image and keeps the container up.

TRIGGER=push is a job that runs after its own build: a push builds the image and runs it once, in the same deploy.

TRIGGER=manual is a job that only you run: a push builds the image, and yard run runs it.

TRIGGER=every 1h is a job on a timer: a push builds the image, and Yard runs it on that interval. The interval counts from the last run, so an edit from every 30m to every 15m ten minutes after a run waits five more minutes.

yard slot new nightly --repo app --trigger "every 1h"

yard run <slot> works on any slot. A job runs once and prints its output. A down service starts. A running one answers <name> is already running. The log of a job run is yard logs --role job --slot nightly.

Reading the host#

yard with no arguments is the status table:

name                   state               cpu    mem    data   rev      domain               deploy
yard                   up 6h               0.1%   60MB   128MB  9c2c7e1  yard.example.com     ok 43s · 2h
yard (caddy)           up 6h               0.3%   24MB
heldso (heldso-app-1)  up 2h · no answer   2.4%   210MB  1.2GB  9c2c7e1  heldso.example.com
nightly                idle                         4MB    9c2c7e1
host: 31% of 4 cores · 294MB of 7.7GB · 1.3GB of 40GB

no answer means the container runs but nothing answered the HTTP probe. N restarts counts Podman's restarts. A revision that is behind the ref shows both, 9c2c7e1 → a1b2c3d. Color marks a line that needs attention: a failed deploy points at yard deploys, a down process points at yard logs.

yard status <name> opens one slot:

heldso  heldso.example.com → heldso:8000
rev 9c2c7e1 · image localhost/heldso:9c2c7e1
data 1.2GB · env /srv/yard/env/heldso.env (3 keys)
 
containers
  heldso-app-1  up 2h  2.4%  210MB
 
deploys
  8  ok  43s  2h  9c2c7e1
 
log
  listening on :8000

yard logs --slot <name> reads containers whose role is app. --role selects app, deploy, or job. --tail defaults to 200; --follow keeps reading.

yard deploys <name> lists the deploys of a slot — the image build, the start, and the total:

id  status  rev      build  start  took  when
8   ok      9c2c7e1  38s    5s     43s   2h

yard deploys <name> <id> prints the apply log, which is the whole story of one deploy. Each step prints a == line:

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

On a failure you see == revert localhost/heldso:<old> followed by == kept localhost/heldso:<old> — the apply container brought the previous image back.

yard doctor checks the host and exits 1 when a check fails:

ok   podman                        podman 5.4.2
ok   network yard                  yard exists
ok   apply image                   localhost/yard-apply exists
ok   caddy                         running
ok   caddy routes                  2 routes
ok   acme                          acme-v02.api.letsencrypt.org:443 open
ok   dns heldso.example.com        203.0.113.10
ok   certificate heldso.example.com  issued 3d ago
ok   https heldso.example.com      HTTP/1.1 200 OK
ok   disk                          31GB free of 40GB
ok   env files                     3 files
ok   hook token                    exists

Yard upgrades itself#

The yard slot is a slot like any other. To upgrade Yard, push to the yard repo — or run yard git yard fetch to move origin/main on the host. The deploy detaches: the apply container outlives the old Yard process, builds and starts the new one, and probes it. If the new process never answers, the apply container brings the old image back. Either way, the next Yard boot records the result of that deploy.

Any app in a container#

Yard never looks inside the image. It builds what the repo describes, starts it, and asks it one HTTP question. So a Go service, a Node app, or a static site behind a small server deploys the same way as a Stario app, as long as it meets the contract:

  • A Containerfile or Dockerfile at the root or in ops/, or a compose file as described in Your own compose file.

  • A process that listens on port 8000 and answers GET / with any HTTP status. That answer is the health probe; a 404 counts as alive.

  • Configuration read from the environment. The slot file's env lines reach the container as variables, and PORT is set to 8000.

  • State under /data if it has any. That directory is the slot's data on the host and survives deploys.

A repo that already runs under docker compose elsewhere usually needs two lines to move in: the yard network with the slot alias on the web service, and env_file: ${APP_ENV}. The error and warning messages above name exactly what is missing.

Next#

How it works walks through one push and everything Yard touches on the host. CLI is the full command list.