Deploying Containers

#infrastructure #servers #podman

Created:

This note establishes the patterns I follow when deploying a new containerized application. Each application is defined declaratively using Podman Quadlets — systemd unit files that Podman's generator converts into container operations automatically.

All commands in this note are run as $APP_USER unless otherwise noted.

Why Quadlets

The traditional approach to running containers under systemd is to write a .service file with podman run in ExecStart and manually handle lifecycle with ExecStop and ExecStopPost. This works but is imperative — you describe how to run the container rather than what the container should be.

Quadlets invert this. You write declarative .container and .network files that describe the desired state. Podman's systemd generator reads these files and produces proper service units, handling container lifecycle, cleanup, and dependency ordering automatically.

Quadlet files for rootless user services live in ~/.config/containers/systemd/.

Directory Structure

Each application gets its own directory under ~/apps/. Persistent data that should survive container recreation lives in a data/ subdirectory.

├── apps
│   └── $APP_NAME
│       └── data/ // Persistent volumes mounted into the container
├── caddy
│   └── // Caddy configuration (see Part 2)
└── www
    └── // Static web content (see Part 2)

Create the directory for a new application.

mkdir -p ~/apps/$APP_NAME/data

This convention keeps application state separate from Caddy configuration and web content, making backups and cleanup straightforward.

Network Quadlet

Create a .network file for each application at ~/.config/containers/systemd/$APP_NAME.network.

[Network]
NetworkName=$APP_NAME

Dedicated networks provide DNS resolution between containers by name and isolate traffic between unrelated applications. A single-container application doesn't strictly need its own network, but creating one costs nothing and means you won't have to reconfigure anything if you later add a sidecar (such as a database) to the stack.

The generated service unit is named $APP_NAME-network.service. Container Quadlets reference it by filename to establish a dependency — the network is created before the container starts.

Container Quadlet

Create a .container file for the application at ~/.config/containers/systemd/$APP_NAME.container.

[Container]
ContainerName=$APP_NAME
Image=$APP_IMAGE
Network=$APP_NAME.network
Volume=%h/apps/$APP_NAME/data:/data:Z

[Service]
Restart=always
RestartSec=10

[Install]
WantedBy=default.target

The key directives.

Directive Purpose
ContainerName Give the container a predictable name
Image The container image to run
Network Reference the .network Quadlet file by filename. Podman creates the network first and establishes a systemd dependency automatically
Volume Persistent storage mount. %h is expanded by systemd to the user's home directory. :Z applies SELinux relabeling for rootless Podman (harmless without SELinux)
Restart=always Restart the container if it exits for any reason
RestartSec=10 Wait 10 seconds before restarting to avoid tight loops

After placing the files, reload the systemd daemon.

systemctl --user daemon-reload

The generator produces a service unit named $APP_NAME.service. The WantedBy=default.target in the Quadlet file means the service is automatically enabled — it will start on boot as long as the .container file exists. Start it.

systemctl --user start $APP_NAME.service

Firewall Rules

Applications fall into two categories for firewall configuration.

HTTP Applications

Applications that serve HTTP traffic are proxied through Caddy. Since Caddy already listens on ports 80 and 443 (allowed during server setup in Part 1), no additional firewall rules are needed. The container only needs to expose its port to the host, and Caddy handles external access.

Non-HTTP Applications

Applications that use protocols other than HTTP (UDP game servers, TCP database connections, etc.) need explicit firewall rules. As the admin user, allow the required ports.

sudo ufw allow $APP_PORT/<protocol> comment '$APP_NAME'

Only expose the minimum ports necessary. If a service has management or administrative ports that you don't need to access remotely, leave them behind the firewall.

Caddy Integration

HTTP applications get a site configuration file that uses the existing proxy snippet from the Caddyfile.

cat > ~/caddy/sites/$APP_DOMAIN.caddyfile <<EOF
$APP_DOMAIN {
        import proxy $APP_DOMAIN localhost:$APP_PORT
}
EOF

For containers using Podman's default port publishing, the address is typically localhost with the published port number.

Reload Caddy to pick up the new configuration.

systemctl --user reload caddy.service

Non-HTTP applications skip Caddy entirely. There is nothing to configure.

Container Hardening

Apply the following directives in the [Container] section to restrict container capabilities.

Directive Purpose
ReadOnly=true Mount the container's root filesystem as read-only. Prevents the process from writing outside of explicitly mounted volumes. Some images may need Tmpfs=/tmp if they write to temporary directories
DropCapability=ALL Drop all Linux capabilities. Use for images that run as a non-root user and don't need any capabilities. Omit for images whose entrypoints chown directories or drop privileges via su-exec/gosu — Podman's default capability set for rootless containers is already minimal and includes what they need
PodmanArgs=--security-opt no-new-privileges Prevent the process from gaining additional privileges via setuid binaries or similar mechanisms
PodmanArgs=--memory 512m Set a memory ceiling. Prevents a misbehaving container from consuming all host memory
PodmanArgs=--cpus 1 Limit CPU usage. Prevents a container from monopolizing CPU time

Rootless Podman already provides strong isolation through user namespaces — the container's root maps to your unprivileged host user. The default capability set is restricted to ~14 capabilities (out of ~40). DropCapability=ALL is worth adding for images that run as a non-root user from the start, but for images that need root-like operations during startup (a common pattern), the defaults are already appropriate.

Automatic Image Updates

Podman can automatically pull new images and restart containers. Add the AutoUpdate directive to the [Container] section.

AutoUpdate=registry

Enable the timer that checks for updates.

systemctl --user enable --now podman-auto-update.timer

This is optional. If you prefer to control when updates happen, pull and restart manually instead.

New Application Checklist

  1. Create the application directory: mkdir -p ~/apps/$APP_NAME/data
  2. Create a .network Quadlet at ~/.config/containers/systemd/$APP_NAME.network
  3. Create a .container Quadlet at ~/.config/containers/systemd/$APP_NAME.container with hardening directives
  4. Pull the image: podman pull $APP_IMAGE
  5. Test the container interactively to verify it starts correctly
  6. If the application serves HTTP: create a Caddy site configuration and reload Caddy
  7. If the application uses non-HTTP ports: add firewall rules with sudo ufw allow
  8. Reload and start: systemctl --user daemon-reload && systemctl --user start $APP_NAME.service
  9. Verify the service is running: systemctl --user status $APP_NAME.service