Most of this book is about apps you write. But a lot of what runs on a small VM is software that somebody else wrote: a password manager, a status page, web analytics, a wiki. You want it on your own machine, behind HTTPS, with its data in a place you can back up. Yard does that too, and you do not need a repo, a forge, or a line of code.
A service on Yard is an app like any other. It gets a name, a container, an env file, a /data directory, a log, and a bind on Caddy. The only difference is where it comes from: an image from a public registry, or a compose file.
All you need is a host. Set up a host covers it, and you can skip its step 4: a forge is only for apps that deploy from a repo.
Pick the way in#
Three ways work, and the service decides which one:
An image suits a service that is one container and keeps its files in
/data, or keeps no files at all.A compose file at a URL suits a service that keeps its files elsewhere, needs a database next to it, or needs a healthcheck. You keep the file at any
https://address, such as a gist.A repo with only a
compose.ymlworks like the URL, but you get history and review, and a push changes the service. This way needs a forge.
The upstream docs usually tell you which one fits. If they say docker run -v …:/data, an image is enough. If they give you a docker-compose.yml, take the compose way.
An image: Vaultwarden#
Vaultwarden is a Bitwarden-compatible password server. It is one container, and it keeps everything in /data. That is exactly what an image app gives a container, so one command runs it:
yard app new --app vault \ --runs docker.io/vaultwarden/server:1.37.4 \ --public vault.example.com \ -e DOMAIN=https://vault.example.comWithout --app, Yard names the app after the image’s last segment, which is server here. That says nothing about the service, so we name it vault.
Yard pulls the image and starts vault-app-1 with the env and /data. Vaultwarden listens on port 80, so Yard reads that from the image and points the bind at vault-app-1:80. A minute later, https://vault.example.com asks you to create an account.
Create yours, and then close the door behind you:
yard env set --app vault SIGNUPS_ALLOWED=falseyard app deploy --app vaultThe tag decides how far the app follows its publisher. 1.37.4 stays on that release, so nothing changes until you choose. When a new release is out, read its notes, and then move the tag to it, for example:
yard app set --app vault --runs docker.io/vaultwarden/server:1.38.0yard app deploy --app vaultA tag like latest follows every release instead. For software you do not control, pair a moving tag with --trigger manual: yard status then shows the new image, and nothing deploys until you run yard app deploy. An image explains how Yard follows a tag.
A compose file: Umami with Postgres#
Umami is web analytics. It needs a Postgres database, so it ships a docker-compose.yml with two services. Here is the upstream file, slightly shortened:
services: umami: image: ghcr.io/umami-software/umami:latest ports: - "3000:3000" environment: DATABASE_URL: postgresql://umami:umami@db:5432/umami APP_SECRET: replace-me-with-a-random-string depends_on: db: condition: service_healthy restart: always db: image: postgres:15-alpine environment: POSTGRES_DB: umami POSTGRES_USER: umami POSTGRES_PASSWORD: umami volumes: - umami-db-data:/var/lib/postgresql/data restart: always healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]volumes: umami-db-data:And here is the same file, ready for Yard:
services: app: image: ghcr.io/umami-software/umami:3.4.1 networks: [yard, default] env_file: [${APP_ENV}] expose: ["3000"] depends_on: db: condition: service_healthy restart: always healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:3000/api/heartbeat"] interval: 10s start_period: 30s db: image: docker.io/library/postgres:15-alpine environment: POSTGRES_DB: umami POSTGRES_USER: umami env_file: [${APP_ENV}] volumes: - ${DATA_DIR}/postgres:/var/lib/postgresql/data restart: always healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]networks: yard: external: true name: yardSix changes, and each one has a reason:
The web service is named
app. Caddy dials<name>-app-1, so the service it reaches must beapp.appjoins two networks. It joinsyard, so Caddy can reach it, and the project’s owndefaultnetwork, so it can reachdb. The database stays offyard, because every container onyardcan reach every other one.ports:is gone. Caddy is the only way in, so the service publishes nothing.expose:tells Yard which port to bind.The passwords leave the file. Both services read the app env (
${APP_ENV}), whichyard envwrites and which only theyarduser can read.The named volume becomes
${DATA_DIR}/postgres. Thus the database lives in the app’s data directory on the host, next to everything else that you back up, andyard app rmremoves it with the app.The tag is pinned, and
apphas a healthcheck. With a healthcheck, a deploy waits until Umami answers, and an upgrade that never answers is rolled back. An image tag in a compose file never moves by itself, solatestwould stay on whatever release Yard pulled first.
Put the file at an https:// address, for example as a raw gist named umami.compose.yml. Then create the app with its settings:
yard app new \ --runs https://gist.githubusercontent.com/you/…/raw/umami.compose.yml \ --public stats.example.com \ -e POSTGRES_PASSWORD=… \ -e DATABASE_URL=postgresql://umami:…@db:5432/umami \ -e APP_SECRET=…Yard names the app umami after the file. Yard fetches the file and runs both containers, umami-app-1 and umami-db-1. It waits for both healthchecks, and binds stats.example.com to port 3000. From then on, Yard fetches the file at every check. When you edit the gist, a push app deploys the new version, and a manual app waits for you.
Prefer history and review? Put the same file in a repo as compose.yml, and run yard app new --port 3000 --public stats.example.com in the checkout. A push then deploys it. Your own compose file has the whole contract.
What Yard does for any service#
Whatever the service is, it gets the same things as your own apps:
A certificate for its domain, which Caddy gets and renews.
One env file on the host, mode 600, that
yard env editchanges.A data directory,
/srv/yard/apps/<name>/data, that survives every deploy.Its output in the journal.
yard app logsreads it, even for a container that is gone.An eye on its source. Every five minutes, Yard asks the registry where the tag points, or fetches the compose file again.
A rollback when an update starts but never gets healthy, as long as the service has a healthcheck.
A row in
yard status, and host checks for DNS and the certificate inyard status --full.
What Yard does not do#
Know the edges before you rely on it:
Public images only. Yard pulls without a registry login, so a private image does not work as an image app.
HTTPS only. Caddy serves port 443 and nothing else. A service that needs another public port, such as mail on 25 or a game server, is outside what Yard runs.
No login in front. Caddy forwards every request it gets. A service with an admin page must have its own login, and you should switch off open sign-ups, as with Vaultwarden above.
/dataonly, for an image app. An image that keeps its files elsewhere, such as/app/dataor/var/lib/postgresql/data, needs a compose file. The Uptime Kuma example shows the one-line mount.No build from a URL. A compose file at a URL runs images only, because there is no checkout to build from.
One VM. There is no failover. When the VM is down, every service on it is down.
Backups are yours. Everything is under
/srv/yard, so a copy of it is a backup. A running database can be caught halfway through a write, so dump it first:
sudo -u yard podman exec umami-db-1 pg_dump -U umami umami > umami.sqlBy the way#
Uptime Kuma is the quickest start, because the Yard repo keeps a working file for it:
yard app new --runs https://raw.githubusercontent.com/Bobowski/yard/main/examples/uptime-kuma.compose.yml --public status.example.com.A service and your own apps share the host and the
yardnetwork. One app can dial another by name, such asvault:80, and a database that other apps use can run as its own image app.yard app rm --app vaultremoves the service and its data, after it asks you to type the name.