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
- Configuration, for the environment file.
- Operations, for the first administrator, updates and backups.