Your own compose file

When one container isn't enough.

Most apps are one container, and for those Yard writes the compose file for you. But sometimes an app wants company: a background worker, a database, a little sidecar. Then you write the compose file yourself, and Yard runs it as it is.

If you've written a compose.yml before, this chapter is short. There's a contract of four lines, and one thing about healthchecks that will save you an afternoon.

The file Yard writes for you#

Start by looking at what you get without a compose file. This is Yard's default, slightly trimmed:

services:
  app:
    build:
      context: ${CHECKOUT}
      dockerfile: ${DOCKERFILE:-Containerfile}
    image: ${IMAGE}
    pull_policy: never
    restart: always
    networks: [yard]
    volumes:
      - ${DATA_DIR}:/data
    env_file:
      - ${APP_ENV}
    environment:
      PROJECT_NAME: ${PROJECT_NAME}
      PROJECT_DATA_DIR: /data
      SHA: ${SHA}
      PORT: "8000"
    expose: ["8000"]
networks:
  yard:
    external: true
    name: yard

Everything in ${…} is filled in by Yard before Compose runs:

  • CHECKOUT is Yard's clone of your repo.

  • IMAGE is the tag for this deploy, localhost/<name>:<sha12>.

  • SHA is the full commit.

  • DATA_DIR is /srv/yard/apps/<name>/data on the host.

  • APP_ENV is the app's env file, the one yard env set writes.

  • PROJECT_NAME is the app's name.

The full file is ops/apply/default.compose.yml in the Yard repo.

The contract#

Put a compose.yml (or compose.yaml) at the repo root or in ops/, and Yard uses it instead of the default. Four lines in it matter:

  • Service app on network yard — required, and the deploy fails without it. Caddy dials <name>-app-1, and it can only reach containers on yard.

  • image: ${IMAGE} and build.context: ${CHECKOUT} on the service that builds — that's how the image gets its commit tag, and how a rollback finds the old one.

  • env_file: [${APP_ENV}] — without it, yard env set has no effect. The deploy log warns.

  • ${DATA_DIR}:/data under volumes: — without it, the app has no safe place for files. The deploy log warns.

Yard does not merge your file with the default one, so the environment: values from the default (PROJECT_DATA_DIR, SHA, …) are yours to add, if your app reads them.

Example: an app and a worker#

Two processes, one image:

services:
  app:
    build:
      context: ${CHECKOUT}
    image: ${IMAGE}
    pull_policy: never
    restart: always
    networks: [yard]
    env_file: [${APP_ENV}]
    volumes: ["${DATA_DIR}:/data"]
    environment:
      SHA: ${SHA}
 
  worker:
    image: ${IMAGE}
    pull_policy: never
    restart: always
    command: ["python", "-m", "myapp.worker"]
    env_file: [${APP_ENV}]
    volumes: ["${DATA_DIR}:/data"]
 
networks:
  yard:
    external: true
    name: yard

Only app has a build: section, so the image is built once and both services run it. The worker isn't on yard, because nothing needs to reach it from outside.

Example: a database next to the app#

services:
  app:
    build:
      context: ${CHECKOUT}
    image: ${IMAGE}
    pull_policy: never
    restart: always
    networks: [yard, default]
    env_file: [${APP_ENV}]
    environment:
      DATABASE_URL: postgresql://app:app@db/app
 
  db:
    image: docker.io/library/postgres:17
    restart: always
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    volumes: ["${DATA_DIR}/postgres:/var/lib/postgresql/data"]
 
networks:
  yard:
    external: true
    name: yard

Look at the networks. app is on two: yard, so Caddy can reach it, and default, the project's private network, so it can reach db. The database is only on default.

The database files go under ${DATA_DIR}, so they survive deploys like any other app data.

Healthchecks#

A healthcheck is what makes a deploy careful. With one, Compose waits until the new container reports healthy, and Yard brings the old image back when it never does. Without one, a container that starts and then breaks goes live anyway.

Here's the afternoon-saver: declare the healthcheck in the compose file, not in the Containerfile. Podman builds images in the OCI format by default, and that format has no place for a HEALTHCHECK line, so Podman drops it with only a warning in the build log.

services:
  app:
    # …
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
      start_period: 30s
      start_interval: 1s
      interval: 5m

start_interval: 1s checks every second while the app starts, so a good deploy goes live fast. interval: 5m keeps the check quiet afterwards. The command runs inside the container, so use a tool the image has: curl if it's installed, or the language runtime as above.

Yard waits up to two minutes for healthy. A healthcheck is the only reason to write a compose file for a one-container app, and it's a good one.

Any language, any server#

Yard never looks inside your image. It builds what the repo describes, starts it, and waits for healthy. So a Go service, a Node app, or nginx serving a static site deploys exactly like a Stario app, as long as it keeps the contract.

A repo that already runs with docker compose somewhere else usually needs three changes to move in: name the web service app, add the yard network, and attach env_file: [${APP_ENV}].

By the way#

  • Yard runs docker-compose against Podman's Docker-compatible API. So the Compose you know is the Compose you get, including depends_on, profiles, and command.

  • --remove-orphans is on. Delete a service from the file, and the next deploy removes its container.

  • Each deploy builds with docker-compose build when the repo has its own compose file, and with a quicker podman build for the default one. The result is the same image.