Deploying Containers
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
- Create the application directory:
mkdir -p ~/apps/$APP_NAME/data - Create a
.networkQuadlet at~/.config/containers/systemd/$APP_NAME.network - Create a
.containerQuadlet at~/.config/containers/systemd/$APP_NAME.containerwith hardening directives - Pull the image:
podman pull $APP_IMAGE - Test the container interactively to verify it starts correctly
- If the application serves HTTP: create a Caddy site configuration and reload Caddy
- If the application uses non-HTTP ports: add firewall rules with
sudo ufw allow - Reload and start:
systemctl --user daemon-reload && systemctl --user start $APP_NAME.service - Verify the service is running:
systemctl --user status $APP_NAME.service