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.comThe 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 staryardyard 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.comgit push -u yard mainyard 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:9c2c7e1remote: == build localhost/heldso:9c2c7e1remote: == start localhost/heldso:9c2c7e1remote: == wait http://heldso:8000/remote: == ready localhost/heldso:9c2c7e1Yard 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.comYard 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=heldsoREF=mainDIR=DOMAIN=heldso.example.comTRIGGER= # env. The container receives these lines.API_TOKEN=secretREF 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 deployyard up 6h 0.1% 60MB 128MB 9c2c7e1 yard.example.com ok 43s · 2hyard (caddy) up 6h 0.3% 24MBheldso (heldso-app-1) up 2h · no answer 2.4% 210MB 1.2GB 9c2c7e1 heldso.example.comnightly idle 4MB 9c2c7e1host: 31% of 4 cores · 294MB of 7.7GB · 1.3GB of 40GBno 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:8000rev 9c2c7e1 · image localhost/heldso:9c2c7e1data 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 :8000yard 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 when8 ok 9c2c7e1 38s 5s 43s 2hyard deploys <name> <id> prints the apply log, which is the whole story of one deploy. Each step prints a == line:
== build localhost/heldso:9c2c7e1built 2026-10-02T14:02:11Z== start localhost/heldso:9c2c7e1== wait http://heldso:8000/== ready localhost/heldso:9c2c7e1On 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.2ok network yard yard existsok apply image localhost/yard-apply existsok caddy runningok caddy routes 2 routesok acme acme-v02.api.letsencrypt.org:443 openok dns heldso.example.com 203.0.113.10ok certificate heldso.example.com issued 3d agook https heldso.example.com HTTP/1.1 200 OKok disk 31GB free of 40GBok env files 3 filesok hook token existsYard 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
ContainerfileorDockerfileat the root or inops/, 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
PORTis set to8000.State under
/dataif 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.