Skip to main content

On a server, with Quadlet

This is the recommended way to run Kinkeep. Podman reads systemd unit files, called Quadlets, and turns each container into a service that starts on boot and restarts on failure. Nothing else is needed: no orchestrator, no daemon of your own.

deploy/quadlet/ in the repository holds units for PostgreSQL, the application, RabbitMQ, and Caddy, which terminates TLS and obtains its certificate on its own. They share one Podman network, and only Caddy publishes ports.

RabbitMQ carries the tasks that are too slow to answer on a request, such as drawing a report's PDF or packing a group's archive. It is optional: an instance without it, and without a worker, serves everything and says so when somebody asks for something that needs one. See decision 0054 in the architecture repository.

Before you start​

  • A server with Podman and systemd, running as root.
  • Ports 80 and 443 open.
  • A domain whose DNS already points at the server. Caddy cannot obtain a certificate before the name resolves, so do this first and let it propagate.

Install​

Run these on the server, as root:

# Configuration and reverse proxy
sudo mkdir -p /etc/kinkeep
sudo cp deploy/quadlet/kinkeep.env.example /etc/kinkeep/kinkeep.env
sudo chmod 600 /etc/kinkeep/kinkeep.env
sudo $EDITOR /etc/kinkeep/kinkeep.env
sudo cp deploy/quadlet/Caddyfile /etc/kinkeep/Caddyfile

# The broker's credentials, which the API's env also names
sudo cp deploy/quadlet/rabbitmq.env.example /etc/kinkeep/rabbitmq.env
sudo chmod 600 /etc/kinkeep/rabbitmq.env
sudo $EDITOR /etc/kinkeep/rabbitmq.env

# Install the units and start
sudo cp deploy/quadlet/*.network deploy/quadlet/*.volume \
deploy/quadlet/*.container /etc/containers/systemd/
sudo systemctl daemon-reload
sudo systemctl start kinkeep-db.service rabbitmq.service kinkeep.service kinkeep-caddy.service

The application runs its database migrations on start, so there is no separate migration step, and the services come up again on boot.

See Configuration for what goes in kinkeep.env. The one value that matters most here is PHX_HOST: it is both the hostname the application builds URLs with and the site address Caddy requests a certificate for.

The client​

Since September 2026 the interface is not part of the application image: it is its own Node server, released as docker.io/kinkeep/web and running beside the API (see decision 0017 in the architecture repository). Install it after the API is up:

# Configuration
sudo cp deploy/quadlet/web.env.example /etc/kinkeep/web.env
sudo chmod 600 /etc/kinkeep/web.env
sudo $EDITOR /etc/kinkeep/web.env

# The unit
sudo cp deploy/quadlet/web.container /etc/containers/systemd/
sudo systemctl daemon-reload
sudo systemctl start web.service

The one value in web.env is API_ORIGIN, the API's address from the client's own container: the name of the application container on the shared podman network, http://kinkeep:4000. It is read per request rather than at build time, because a standalone Next build bakes its rewrites when the image is built.

The interface now answers where the API used to, so the reverse proxy must split the paths instead of passing everything to the application: /api, /media and /feeds belong to the API, and every other path belongs to the client.

/feeds is where a calendar subscription lives. It sits outside /api on purpose: the address is copied into somebody's calendar application and read from there for years, so it must never move, and the versioned API promises that what is under it may. It has to reach the API directly, or a subscription added on this host will silently stop updating. See decision 0077.

app.example.org {
handle /api/* {
reverse_proxy kinkeep:4000
}
handle /media/* {
reverse_proxy kinkeep:4000
}
handle /feeds/* {
reverse_proxy kinkeep:4000
}
handle {
reverse_proxy web:3000
}
}

The client's image updates the same way the application's does, by watching the registry.

The PDF worker​

Drawing a report is a task rather than a request, so it is done by a worker of its own, released from the pdf-worker repository as docker.io/kinkeep/pdf-worker. It needs the broker and the API, and publishes nothing itself: the API stores the file and serves it. The units below live in that repository's deploy/quadlet/.

# Configuration
sudo cp deploy/quadlet/pdf-worker.env.example /etc/kinkeep/pdf-worker.env
sudo chmod 600 /etc/kinkeep/pdf-worker.env
sudo $EDITOR /etc/kinkeep/pdf-worker.env

# The unit
sudo cp deploy/quadlet/pdf-worker.container /etc/containers/systemd/
sudo systemctl daemon-reload
sudo systemctl start pdf-worker.service

pdf-worker.env names RABBITMQ_URL, the broker by its container name and with the credentials from rabbitmq.env, and API_ORIGIN, the API by its container name, http://kinkeep:4000. The worker holds no secret of its own: each task carries a token signed for that one job, and the worker only carries it back. See decision 0055.

The archive worker​

Packing a group or an animal is a task too, so it is done by a worker of its own, released from the archive-worker repository as docker.io/kinkeep/archive-worker. It reads the documents the API has already written, fetches the files they name, builds the zip and hands it back; the API stores it and serves it. The units live in that repository's deploy/quadlet/.

# Configuration
sudo cp deploy/quadlet/archive-worker.env.example /etc/kinkeep/archive-worker.env
sudo chmod 600 /etc/kinkeep/archive-worker.env
sudo $EDITOR /etc/kinkeep/archive-worker.env

# The unit
sudo cp deploy/quadlet/archive-worker.container /etc/containers/systemd/
sudo systemctl daemon-reload
sudo systemctl start archive-worker.service

archive-worker.env names the same two values, RABBITMQ_URL and API_ORIGIN, and nothing else. Each worker consumes one queue: the PDF worker takes report.pdf and the archive worker takes export, and each tells the kinds of task routed to it apart by their type. See decision 0072.

:::tip Check the units against your own Podman first Quadlet gained directives over time, and a server on a long term distribution can be several versions behind a current workstation. You can see exactly what your Podman makes of the units, without installing anything, with:

QUADLET_UNIT_DIRS=deploy/quadlet \
/usr/lib/systemd/system-generators/podman-system-generator --dryrun

It prints the podman run command it would generate for each unit, and warns about anything it does not understand. :::

Already running something else on the same host?​

The units are self-contained on purpose, so that they work on a server with nothing else on it. Two applications cannot both publish ports 80 and 443, so if this host already serves another site, do not install kinkeep-caddy.container. Instead, join your existing proxy to the kinkeep network and give it a block for the new hostname.

With Caddy, that means adding one line to its unit:

Network=kinkeep.network

and one block to its Caddyfile. With only the API installed, the application answers every path:

app.example.org {
reverse_proxy kinkeep:4000
}

With the client installed too, use the split from The client instead, so /api, /media and /feeds reach the API and everything else reaches the client.

Validate the configuration before restarting the proxy, because a proxy that fails to start takes every site on the host with it:

podman run --rm -v /etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro \
docker.io/library/caddy:2 caddy validate --config /etc/caddy/Caddyfile

Everything else, container names, volumes and the network, is prefixed so that it does not collide with another deployment on the same machine.

Next​