docs: add podman-compose and podman-systemd howtos

- podman-compose.md — full compose.yml reference, services, healthchecks,
  dependencies, networking, volumes, override files
- podman-systemd.md — Quadlet files (.container, .volume, .network, .kube,
  .build), multi-container stacks, lingering, auto-update, secrets
This commit is contained in:
user
2026-02-21 21:18:26 +01:00
parent 42cf66377e
commit 5aaa290b76
2 changed files with 754 additions and 0 deletions

372
topics/podman-compose.md Normal file
View File

@@ -0,0 +1,372 @@
# Podman Compose
> Multi-container orchestration with Compose files — podman's docker-compose equivalent.
## Setup
```bash
# Install
sudo apt install podman-compose # Debian/Ubuntu
sudo dnf install podman-compose # Fedora/RHEL
pip install podman-compose # pip fallback
# Verify
podman-compose version
```
## Compose File Reference
```yaml
# compose.yml (or docker-compose.yml — both recognized)
services:
web:
image: nginx:alpine
container_name: web
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro,Z
- ./nginx.conf:/etc/nginx/nginx.conf:ro,Z
networks:
- frontend
depends_on:
app:
condition: service_healthy
restart: unless-stopped
environment:
TZ: Europe/Brussels
app:
build:
context: .
dockerfile: Containerfile
args:
APP_VERSION: "2.1"
container_name: app
expose:
- "8000" # internal only (not published)
ports:
- "8000:8000"
volumes:
- ./src:/app/src:Z
- app-data:/app/data
env_file:
- .env
environment:
APP_ENV: production
DB_HOST: db
DB_NAME: myapp
networks:
- frontend
- backend
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: on-failure
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
db:
image: postgres:16-alpine
container_name: db
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro,Z
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD_FILE: /run/secrets/db_pass
POSTGRES_DB: myapp
secrets:
- db_pass
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432" # localhost only
redis:
image: redis:7-alpine
container_name: redis
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
volumes:
pgdata:
redis-data:
app-data:
networks:
frontend:
backend:
secrets:
db_pass:
file: ./secrets/db_password.txt
```
## Commands
```bash
# Start / stop
podman-compose up -d # detached
podman-compose up -d --build # rebuild before start
podman-compose down # stop + remove containers
podman-compose down -v # also remove volumes
# Specific services
podman-compose up -d web app # start only these
podman-compose stop db # stop one service
podman-compose restart app
# Build
podman-compose build # all services with build:
podman-compose build app # specific service
podman-compose build --no-cache app # force rebuild
# Logs
podman-compose logs # all services
podman-compose logs -f app # follow one service
podman-compose logs --tail 100 app db # last 100 lines, multiple
# Status
podman-compose ps
podman-compose top # processes in each container
# Execute
podman-compose exec app bash
podman-compose exec db psql -U appuser -d myapp
podman-compose exec -T app python manage.py migrate # no TTY (scripts)
# Run one-off command (new container)
podman-compose run --rm app python manage.py shell
# Pull latest images
podman-compose pull
```
## Service Configuration
### Build Options
```yaml
services:
app:
build:
context: ./app # build context directory
dockerfile: Containerfile # relative to context
args:
APP_VERSION: "2.0"
BUILD_ENV: production
target: production # multi-stage target
cache_from:
- myapp:latest
image: myapp:latest # tag the built image
```
### Environment Variables
```yaml
services:
app:
# Inline key-value
environment:
APP_ENV: production
DEBUG: "false"
# From file
env_file:
- .env # default
- .env.production # override
# Pass host variable through (no value = inherit)
environment:
- HOME
```
`.env` file (auto-loaded for variable substitution in compose.yml):
```bash
# .env
APP_VERSION=2.1
POSTGRES_PASSWORD=secret
EXTERNAL_PORT=8080
```
```yaml
# Variable substitution in compose.yml
services:
app:
image: myapp:${APP_VERSION}
ports:
- "${EXTERNAL_PORT:-8080}:80" # with default
```
### Healthchecks
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s # time between checks
timeout: 10s # max time per check
retries: 3 # failures before unhealthy
start_period: 30s # grace period after start
start_interval: 5s # interval during start_period
```
| Test form | Example |
|-----------|---------|
| `CMD` | `["CMD", "curl", "-f", "http://localhost/health"]` |
| `CMD-SHELL` | `["CMD-SHELL", "pg_isready \|\| exit 1"]` |
| `NONE` | Disable inherited healthcheck |
### Dependency Conditions
```yaml
depends_on:
db:
condition: service_started # default — just started
db:
condition: service_healthy # wait for healthcheck to pass
migrations:
condition: service_completed_successfully # wait for exit 0
```
### Networking
```yaml
services:
app:
networks:
frontend:
aliases:
- api # extra DNS name on this network
backend:
ipv4_address: 10.89.0.10 # static IP
networks:
frontend:
driver: bridge
backend:
driver: bridge
ipam:
config:
- subnet: 10.89.0.0/24
gateway: 10.89.0.1
external-net:
external: true # pre-existing network
```
Containers on the same network resolve each other by service name.
### Volumes
```yaml
services:
app:
volumes:
# Bind mounts
- ./src:/app/src:Z # relative path
- /host/path:/container/path:ro # absolute, read-only
# Named volumes
- app-data:/app/data
# Tmpfs
- type: tmpfs
target: /tmp
tmpfs:
size: 100000000 # 100MB
volumes:
app-data: # managed by podman
shared-data:
external: true # pre-existing volume
backup:
driver: local
driver_opts:
type: none
o: bind
device: /mnt/backup/app
```
## Development Patterns
### Override File
```yaml
# compose.override.yml — auto-merged with compose.yml
services:
app:
build:
context: .
volumes:
- ./src:/app/src:Z # live reload
environment:
APP_ENV: development
DEBUG: "true"
ports:
- "5678:5678" # debugger port
command: python -m debugpy --listen 0.0.0.0:5678 -m src.app
```
```bash
# Production (skip override)
podman-compose -f compose.yml up -d
# Explicit files
podman-compose -f compose.yml -f compose.prod.yml up -d
```
### Wait-for Pattern
```yaml
services:
app:
depends_on:
db:
condition: service_healthy
# OR with a wrapper script:
command: ["./wait-for-it.sh", "db:5432", "--", "python", "app.py"]
```
## Gotchas
- `podman-compose` and `docker-compose` are mostly compatible but edge cases differ — test both if porting
- `:Z` on volumes is required for SELinux (Fedora/RHEL) — omitting it causes silent permission errors
- `depends_on: service_healthy` requires a `healthcheck` on the dependency — no healthcheck = hangs
- `env_file` values are literal — no shell expansion, no quotes needed (quotes become part of value)
- `.env` is loaded for compose variable substitution only — use `env_file` to pass vars into containers
- Named volumes persist across `down` — only `down -v` removes them
- `podman-compose` runs containers independently, not in a pod by default — use `--podman-run-args="--pod"` or configure per-project
- DNS resolution between containers only works on user-created networks
- `container_name` prevents scaling (`podman-compose up --scale app=3`)
- Secret files are mounted read-only at `/run/secrets/<name>` inside the container
## See Also
- `podman` — core container commands
- `podman-systemd` — Quadlet files for production deployment
- [Compose spec](https://compose-spec.io/)

382
topics/podman-systemd.md Normal file
View File

@@ -0,0 +1,382 @@
# Podman Systemd & Quadlet
> Run containers as systemd services — Quadlet is the modern, declarative approach.
## Overview
| Method | Status | Use case |
|--------|--------|----------|
| `podman generate systemd` | Deprecated | Legacy, existing setups |
| Quadlet `.container` files | **Current** | New deployments |
## Quadlet
Quadlet converts declarative unit files into systemd services. Place files in the appropriate directory and `daemon-reload` — systemd does the rest.
### File Locations
| User | Path |
|------|------|
| Rootless | `~/.config/containers/systemd/` |
| Root | `/etc/containers/systemd/` |
### Unit Types
| Extension | Purpose |
|---------------|----------------------------|
| `.container` | Container definition |
| `.volume` | Named volume |
| `.network` | Container network |
| `.kube` | Kubernetes YAML deployment |
| `.image` | Image pull/build |
| `.build` | Image build instructions |
## .container — Single Service
```ini
# ~/.config/containers/systemd/myapp.container
[Unit]
Description=My Application
After=network-online.target
[Container]
Image=docker.io/library/myapp:latest
ContainerName=myapp
PublishPort=8080:80
Volume=myapp-data.volume:/app/data
Volume=%h/git/myapp/config:/app/config:ro,Z
Network=myapp.network
Environment=APP_ENV=production
Environment=TZ=Europe/Brussels
EnvironmentFile=%h/git/myapp/.env
Secret=db_pass
User=1000
Exec=--port 8080
# Health check
HealthCmd=curl -f http://localhost:80/health
HealthInterval=30s
HealthTimeout=5s
HealthRetries=3
HealthStartPeriod=10s
# Resource limits
PodmanArgs=--memory 512m --cpus 1.5
[Service]
Restart=on-failure
RestartSec=5
TimeoutStartSec=60
TimeoutStopSec=30
[Install]
WantedBy=default.target
```
### Common Container Directives
| Directive | Purpose |
|-------------------|--------------------------------------------------|
| `Image` | Container image reference |
| `ContainerName` | Explicit container name |
| `PublishPort` | Port mapping (`host:container`) |
| `Volume` | Volume mount (named or bind) |
| `Network` | Network to join (reference `.network` file) |
| `Environment` | Key=value env var (repeatable) |
| `EnvironmentFile` | Load env from file |
| `Secret` | Mount secret from podman secret store |
| `User` | Container user (UID or name) |
| `Exec` | Arguments appended to entrypoint |
| `Entrypoint` | Override image entrypoint |
| `PodmanArgs` | Extra `podman run` flags |
| `Label` | Container label (repeatable) |
| `Tmpfs` | Tmpfs mount |
| `ReadOnly=true` | Read-only root filesystem |
| `AutoUpdate=registry` | Enable `podman auto-update` |
| `Notify=healthy` | Mark service ready when healthcheck passes |
| `HealthCmd` | Health check command |
| `HealthInterval` | Time between health checks |
| `HealthTimeout` | Max time per check |
| `HealthRetries` | Failures before unhealthy |
| `HealthStartPeriod` | Grace period after start |
### Specifiers
| Specifier | Value |
|-----------|-----------------------------|
| `%h` | User home directory |
| `%n` | Unit name |
| `%N` | Unit name without suffix |
| `%t` | Runtime directory (`$XDG_RUNTIME_DIR`) |
| `%S` | State directory |
## .volume
```ini
# ~/.config/containers/systemd/myapp-data.volume
[Volume]
VolumeName=myapp-data
Label=app=myapp
```
Reference in `.container` as `Volume=myapp-data.volume:/container/path`.
## .network
```ini
# ~/.config/containers/systemd/myapp.network
[Network]
NetworkName=myapp-net
Subnet=10.89.1.0/24
Gateway=10.89.1.1
Label=app=myapp
```
Reference in `.container` as `Network=myapp.network`.
## .image
```ini
# ~/.config/containers/systemd/myapp.image
[Image]
Image=docker.io/library/myapp:latest
```
## .build
```ini
# ~/.config/containers/systemd/myapp.build
[Build]
ImageTag=myapp:latest
SetWorkingDirectory=unit
File=Containerfile
```
Reference in `.container` as `Image=myapp.build`.
## .kube — Kubernetes YAML
```ini
# ~/.config/containers/systemd/mystack.kube
[Unit]
Description=My application stack
[Kube]
Yaml=%h/git/myapp/pod.yml
PublishPort=8080:80
Network=myapp.network
[Service]
Restart=on-failure
[Install]
WantedBy=default.target
```
## Multi-Container Stack
```ini
# ~/.config/containers/systemd/app.network
[Network]
NetworkName=app-net
```
```ini
# ~/.config/containers/systemd/app-db.volume
[Volume]
VolumeName=app-db-data
```
```ini
# ~/.config/containers/systemd/app-db.container
[Unit]
Description=PostgreSQL for app
[Container]
Image=docker.io/library/postgres:16-alpine
ContainerName=app-db
Volume=app-db.volume:/var/lib/postgresql/data
Network=app.network
Environment=POSTGRES_USER=appuser
Environment=POSTGRES_DB=myapp
EnvironmentFile=%h/.secrets/db.env
HealthCmd=pg_isready -U appuser -d myapp
HealthInterval=10s
HealthRetries=5
[Service]
Restart=on-failure
[Install]
WantedBy=default.target
```
```ini
# ~/.config/containers/systemd/app-web.container
[Unit]
Description=Application web server
After=app-db.service
Requires=app-db.service
[Container]
Image=docker.io/library/myapp:latest
ContainerName=app-web
PublishPort=8080:8000
Volume=%h/git/myapp/static:/app/static:ro,Z
Network=app.network
Environment=DB_HOST=app-db
Environment=DB_NAME=myapp
EnvironmentFile=%h/.secrets/db.env
Notify=healthy
HealthCmd=curl -f http://localhost:8000/health
HealthInterval=30s
HealthStartPeriod=15s
[Service]
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
```
## Workflow
```bash
# After creating / editing Quadlet files:
systemctl --user daemon-reload
# Start
systemctl --user start app-web.service
systemctl --user start app-db.service
# Enable on boot (requires lingering)
loginctl enable-linger $USER
systemctl --user enable app-web.service
systemctl --user enable app-db.service
# Status / logs
systemctl --user status app-web.service
journalctl --user -u app-web.service -f
# Stop
systemctl --user stop app-web.service
# Validate quadlet files (dry run)
/usr/lib/systemd/system-generators/podman-system-generator --user --dryrun
```
## Rootless & Lingering
Rootless (non-root) user services are tied to the user's login session by default. Without lingering, systemd kills all user services when the user logs out — containers included.
```bash
# Enable lingering (services survive logout)
loginctl enable-linger $USER
# Check status
loginctl show-user $USER --property=Linger
ls /var/lib/systemd/linger/ # file per lingered user
# Disable
loginctl disable-linger $USER
```
| Linger state | Behavior |
|---|---|
| Disabled (default) | User services start on login, stop on logout |
| Enabled | User services start at boot, persist after logout |
Without lingering:
- Containers stop when SSH session ends
- Timers don't fire when logged out
- `WantedBy=default.target` only activates at login
With lingering:
- `systemctl --user enable` services start at boot
- Containers run permanently, like root services
- Required for any production rootless container
### Rootless vs Root Quadlet
| | Rootless | Root |
|---|---|---|
| Quadlet path | `~/.config/containers/systemd/` | `/etc/containers/systemd/` |
| systemctl | `systemctl --user` | `systemctl` |
| journalctl | `journalctl --user -u` | `journalctl -u` |
| Ports < 1024 | Not allowed (unless sysctl) | Allowed |
| Lingering | Required | Not applicable |
| Security | No root needed | Runs as root |
## Auto-Update
```ini
# In .container
[Container]
AutoUpdate=registry
Label=io.containers.autoupdate=registry
```
```bash
# Check for updates
podman auto-update --dry-run
# Apply updates
podman auto-update
# Systemd timer (auto-runs daily)
systemctl --user enable --now podman-auto-update.timer
```
## Legacy: generate systemd
```bash
# From existing container (deprecated)
podman generate systemd --new --name myapp --files
mv container-myapp.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now container-myapp.service
```
`--new` creates a new container each start (preferred over restarting existing).
## Secrets
```bash
# Create secret
echo -n "s3cret" | podman secret create db_pass -
podman secret create app_key ./keyfile.txt
# List / inspect
podman secret ls
podman secret inspect db_pass
# Use in Quadlet
# [Container]
# Secret=db_pass
# Mounted at /run/secrets/db_pass inside container
# Remove
podman secret rm db_pass
```
## Gotchas
- `daemon-reload` is required after any Quadlet file change — systemd caches unit definitions
- Quadlet service names are derived from the filename: `myapp.container` becomes `myapp.service`
- `After=` and `Requires=` reference the `.service` name, not the `.container` filename
- Rootless containers need `loginctl enable-linger` or they stop when the user logs out
- `%h` in Quadlet expands to home directory — don't use `$HOME` or `~`
- Volume references must match the `.volume` filename: `Volume=mydata.volume:/path`
- The Quadlet generator path varies by distro — check with `rpm -ql podman | grep generator` or `dpkg -L podman`
- Quadlet `.build` requires the source directory — set `SetWorkingDirectory=unit` to use the Quadlet file's location
- `Notify=healthy` requires a `HealthCmd` — without it, the service never reaches "ready"
- `podman auto-update` only works with fully qualified image names (include registry)
## See Also
- `podman` — core container commands
- `podman-compose` — compose file orchestration
- `systemd` — service management fundamentals
- [Quadlet docs](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html)