Skip to content
openplate

The app

Podman

Running these compose files and containers under Podman instead of Docker: the podman compose vs. podman-compose distinction, and the rootless notes

Every docker compose and docker run command in these docs also works with Podman. Swap docker for podman. This page documents the naming trap and rootless differences once. Other docs link here directly instead of repeating them.

podman compose is not podman-compose

These are two different tools. The wrong one runs a less compatible implementation without warning.

  • podman compose (a space) is a subcommand built into Podman. It is a thin wrapper. It finds an external compose provider on your machine, either docker-compose or podman-compose, and hands the command to it. If you have docker-compose installed, Podman uses it first. It is the original implementation of the Compose spec. Run podman compose version to see the provider printed in a banner.
  • podman-compose (a hyphen) is a separate, Python-based implementation of the Compose spec. It runs on its own without Docker. It supports less of the spec. Its support for depends_on: condition: service_healthy is weaker, and it names networks differently by default.

With no provider, podman compose does nothing. A clean Ubuntu 24.04 install with only podman answers every podman compose command with Error: looking up compose provider failed. Install a provider first:

bash
sudo apt install podman podman-compose
podman compose version     # names /usr/bin/podman-compose, version 1.0.6

On that machine, podman compose and podman-compose run the same program. The healthchecks in these compose files work under it. compose.yml and compose.core.yml reported healthy with podman 4.9.3 and podman-compose 1.0.6, including the Postgres wait. The healthchecks use one plain shell line (wget -q -O /dev/null http://127.0.0.1:3000/...) for this reason. The older node -e "fetch(...)" syntax reached podman-compose 1.0.6 as a broken shell line. podman ps then reported unhealthy continuously while the app answered.

Every compose file that starts Postgres (compose.core.yml, compose.full.yml, and openplate-core's quickstart compose.yml) uses depends_on: condition: service_healthy to gate a service on the Postgres healthcheck. If you use a different provider, verify that it supports that condition.

Rootless notes

Podman runs rootless by default. Containers run as your own user, not root. This provides better security. It also introduces three differences from Docker to know before you self-host.

SELinux volume labels. On a host with SELinux enforcing, which is default on Fedora, a container cannot read or write a bind-mounted host directory without a container access label. Add :Z to the mount if only one container uses it. Add :z if multiple containers share it, for example -v ./pg-data:/var/lib/postgresql:Z. None of the compose files in these three repositories bind-mount a host directory. All of them use named volumes, which Podman labels automatically. This note applies only if you edit a volumes: entry to use a host path. That change affects sync rungs 2 and 4 for Postgres, and inference rung 3 for model weights.

Ports below 1024. A rootless container cannot bind a host port below 1024 without permission, for example via sudo sysctl net.ipv4.ip_unprivileged_port_start=80. None of the default ports in these compose files (3000, 3001, 8300) need this. This issue occurs only if you remap a port to 80 or 443 to bypass a reverse proxy. Because each rung publishes at least one port, this can affect any of the 4 rungs.

Rootless Postgres. The Postgres container runs as a non-root user. That user must own its data directory. A named volume solves this automatically, which apps/core/docker/compose.yml, compose.core.yml, and compose.full.yml already use. Podman creates the named volume with correct ownership. If you switch to a bind-mounted host directory, set ownership from the host first with podman unshare chown -R 70:70 ./pg-data. The user ID 70 matches the postgres user in the pinned postgres:18-alpine image; Debian images use 999. Without this step, the container fails on startup with a permissions error. This affects rungs 2 and 4.

Quadlet units

Quadlet uses neither podman compose nor podman-compose: systemd starts each container with podman itself. If you only want Quadlet, skip the provider install above.

Every compose file above also ships as rootless systemd units. They are generated by scripts/quadlet.sh and stored in docker/quadlet/. A unit set persists across reboots under systemctl --user without an active compose process. Each directory includes a README with installation steps, the env files each unit reads, and test run logs:

Pick one path: compose or Quadlet, never both. A custom systemd unit running podman compose up and a Quadlet unit set perform the same task. If you install both, they conflict at boot over port 3000. Remove the existing service before switching to Quadlet. To remove Quadlet, follow the uninstall steps in the scenario README.

Two things to know before you start, on any Podman:

  • There is no systemctl --user enable step. Podman's generator creates the services. You cannot enable generated units. Running that command returns Failed to enable unit: Unit /run/user/1000/systemd/generator/app.service is transient or generated. This is normal behavior. Automatic startup relies on the [Install] WantedBy=default.target line in each unit, along with loginctl enable-linger "$USER". That command starts your user systemd instance at boot instead of first login.
  • Every setting goes in <unit>.env, never in the unit. Each unit reads two env files from its own directory: <unit>.defaults.env, which ships with the units and holds the compose defaults, then <unit>.env, which is yours. Podman reads them in that order, so a line in yours wins. Your file must exist, even when empty, or the container does not start. An update copies the units and the defaults files again and leaves your file alone. The scenario README lists the values people change.

Podman 4.9, Ubuntu 24.04's version, differs from Podman 5 in two ways:

  • It ignores Notify=healthy, which requires Podman 5.0 or newer. As a result, systemctl --user start exits roughly one second after container launch, before the healthcheck completes. A unit with Requires= will not wait for dependent health status. Test container status manually:
    bash
    until [ "$(podman inspect --format '{{.State.Health.Status}}' systemd-app)" = healthy ]; do sleep 5; done
  • It reads no drop-in directory (app.container.d/*.conf). Files placed there produce no effect. The units need none: every value you can change is read from <unit>.env.

Edit this page on GitHub