Self-host a service

Someone else wrote the software. You run it, on your own VM.

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.yml works 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.com

Without --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=false
yard app deploy --app vault

The 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.0
yard app deploy --app vault

A 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: yard

Six changes, and each one has a reason:

  1. The web service is named app. Caddy dials <name>-app-1, so the service it reaches must be app.

  2. app joins two networks. It joins yard, so Caddy can reach it, and the project’s own default network, so it can reach db. The database stays off yard, because every container on yard can reach every other one.

  3. ports: is gone. Caddy is the only way in, so the service publishes nothing. expose: tells Yard which port to bind.

  4. The passwords leave the file. Both services read the app env (${APP_ENV}), which yard env writes and which only the yard user can read.

  5. 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, and yard app rm removes it with the app.

  6. The tag is pinned, and app has 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, so latest would 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 edit changes.

  • A data directory, /srv/yard/apps/<name>/data, that survives every deploy.

  • Its output in the journal. yard app logs reads 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 in yard 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.

  • /data only, for an image app. An image that keeps its files elsewhere, such as /app/data or /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.sql

By 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 yard network. One app can dial another by name, such as vault:80, and a database that other apps use can run as its own image app.

  • yard app rm --app vault removes the service and its data, after it asks you to type the name.