In my last post about Quadlets, we looked at managing single-container deployments with systemd. That works great for simple services, but what about when you need several containers that work together? That’s where pod quadlets come in.
What’s a Pod, Anyway?
A pod is a group of containers that share the same network namespace. They can all talk to each other over localhost
like they’re running on the same machine (because from their perspective, they are).
In Kubernetes, a pod is the smallest deployable unit - you can’t run a standalone container. In Podman, pods are optional - you can run individual containers just fine, but pods give you a convenient way to group related containers together.
Why use a pod? Take Paperless-NGX, the document management system I’m running. It needs:
- The main application
- Redis for caching
- Tika for parsing documents
- Gotenberg for PDF conversion
All of these need to communicate with each other. I could expose ports and configure networking between separate containers, or I could throw them in a pod and let them use localhost
. Much simpler.
The key difference from Kubernetes: in Podman, you choose to use pods when it makes sense. For simple single-container services (like we saw in the previous quadlet post), you don’t need a pod at all.
Organizing Quadlet Files
Before we dive in, here’s something useful: you can use subdirectories in ~/.config/containers/systemd/
. Quadlet recursively searches for files, so you can organize related services together:
~/.config/containers/systemd/
├── paperless-ngx/
│ ├── paperless-ngx.pod
│ ├── redis.container
│ ├── tika.container
│ ├── gotenberg.container
│ └── app.container
├── vaultwarden.container
└── uptime-kuma.container
Much cleaner than dumping everything in one directory.
My Paperless-NGX Setup
Let me show you what I’m actually running. Here’s the data directory layout:
/srv/containers/paperless-ngx/
├── paperless-app/
│ ├── consume/ # Drop PDFs here
│ ├── data/ # SQLite database
│ ├── export/ # Exports go here
│ └── media/ # All your documents
└── paperless-redis/ # Redis persistence
Setting Up Directories
Before creating any quadlet files, let’s set up the directory structure properly with SELinux contexts.
First, create the base directory and set the correct SELinux context:
sudo mkdir -p /srv/containers
sudo semanage fcontext -a -t container_file_t "/srv/containers(/.*)?"
sudo restorecon -R /srv/containers
This sets the base context to container_file_t
, which allows containers to access these directories.
Change ownership to your user (for rootless containers):
sudo chown -R $USER:$USER /srv/containers
Create the specific directories for Paperless:
mkdir -p /srv/containers/paperless-ngx/{paperless-app/{data,media,export,consume},paperless-redis}
Now we’re ready to create the quadlet files.
The Quadlet Files
Let’s create the pod and containers using quadlet files in ~/.config/containers/systemd/paperless-ngx/
.
paperless-ngx.pod
- The pod definition:
[Unit]
Description=Paperless-NGX Pod
[Pod]
PodName=paperless-ngx
PublishPort=8000:8000
[Install]
WantedBy=default.target
This creates the pod and exposes port 8000 to the host. The [Install]
section makes everything start at boot.
paperless-ngx-redis.container
- Redis cache:
[Unit]
Description=Paperless Redis
PartOf=paperless-ngx.service
[Container]
ContainerName=paperless-ngx-redis
Image=docker.io/library/redis:7
Pod=paperless-ngx.pod
Volume=/srv/containers/paperless-ngx/paperless-redis:/data:Z
[Service]
Restart=always
paperless-ngx-tika.container
- Document parser:
[Unit]
Description=Paperless Tika
PartOf=paperless-ngx.service
[Container]
ContainerName=paperless-ngx-tika
Image=ghcr.io/paperless-ngx/tika:latest
Pod=paperless-ngx.pod
AutoUpdate=registry
[Service]
Restart=always
paperless-ngx-gotenberg.container
- PDF converter:
[Unit]
Description=Paperless Gotenberg
PartOf=paperless-ngx.service
[Container]
ContainerName=paperless-ngx-gotenberg
Image=docker.io/gotenberg/gotenberg:8
Pod=paperless-ngx.pod
Exec=gotenberg --chromium-disable-javascript=true --chromium-allow-list=file:///tmp/.*
[Service]
Restart=always
paperless-ngx-app.container
- The main application:
[Unit]
Description=Paperless-NGX Application
PartOf=paperless-ngx.service
After=paperless-ngx-redis.service paperless-ngx-tika.service paperless-ngx-gotenberg.service
[Container]
ContainerName=paperless-ngx-app
Image=ghcr.io/paperless-ngx/paperless-ngx:latest
Pod=paperless-ngx.pod
AutoUpdate=registry
EnvironmentFile=/srv/containers/paperless-ngx/.env
Volume=/srv/containers/paperless-ngx/paperless-app/data:/usr/src/paperless/data:Z
Volume=/srv/containers/paperless-ngx/paperless-app/media:/usr/src/paperless/media:Z
Volume=/srv/containers/paperless-ngx/paperless-app/export:/usr/src/paperless/export:Z
Volume=/srv/containers/paperless-ngx/paperless-app/consume:/usr/src/paperless/consume:Z
[Service]
Restart=always
Notice the EnvironmentFile=
line - instead of cluttering the quadlet with environment variables, I keep all config in a separate .env
file. More on that in the secrets section below.
How the Pieces Fit Together
Pod membership
Every container specifies Pod=paperless-ngx.pod
, which tells quadlet to put them all in the same pod. They automatically share the same network namespace.
The localhost magic
In the .env
file, the app configuration references other services via localhost:
PAPERLESS_REDIS=redis://127.0.0.1:6379
PAPERLESS_TIKA_ENDPOINT=http://127.0.0.1:9998
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://127.0.0.1:3000
All 127.0.0.1
! Because they’re in the same pod, Redis on port 6379, Tika on 9998, and Gotenberg on 3000 are all accessible via localhost.
Startup ordering
After=paperless-ngx-redis.service paperless-ngx-tika.service paperless-ngx-gotenberg.service
This ensures the app container waits for the support services to be running first.
Container naming
ContainerName=paperless-ngx-app
This sets the actual container name. Without it, Podman would use a generated name like systemd-paperless-ngx-app
. By naming both the .container
file and setting ContainerName=
, you get consistent, readable names everywhere.
Auto-updates
AutoUpdate=registry
Set this on containers where you want automatic updates. Podman can automatically pull newer images and recreate those containers on a schedule. I’ve got this set up to run daily via a systemd timer:
systemctl --user enable podman-auto-update.timer
This timer runs podman auto-update
once a day, checking all containers marked with AutoUpdate=registry
and updating them if newer images are available. You can check when it last ran and when it’s scheduled next:
systemctl --user list-timers podman-auto-update.timer
If you want to trigger an update manually, just run:
podman auto-update
Mixing image versions
Look at the versions I’m using:
Image=docker.io/library/redis:7 # Pinned to major version
Image=ghcr.io/paperless-ngx/tika:latest # Always latest
Image=docker.io/gotenberg/gotenberg:8 # Pinned to major version
I pin infrastructure (Redis, Gotenberg) to major versions for stability, but let Paperless-NGX and Tika float to latest
for new features. Adjust based on your risk tolerance.
Deploying the Pod
Create all the quadlet files in ~/.config/containers/systemd/paperless-ngx/
, then:
systemctl --user daemon-reload
That’s it! The quadlet generator will:
- Create the pod
- Create all 4 containers
- Link them to the pod
- Set up dependencies
- Enable everything to start at boot
Check the status:
systemctl --user status paperless-ngx
systemctl --user status paperless-ngx-redis paperless-ngx-tika paperless-ngx-gotenberg paperless-ngx-app
View logs:
journalctl --user -u paperless-ngx -f
journalctl --user -u paperless-ngx-app -f
Managing the Pod
Stop everything:
systemctl --user stop paperless-ngx
Start it again:
systemctl --user start paperless-ngx
Restart a single container:
systemctl --user restart paperless-ngx-app
Keeping Secrets Out of Quadlet Files
Here’s something I learned the hard way: don’t put passwords and API keys directly in your quadlet files. I mean, you can, but it’s messy and makes version control awkward. Plus, if you’re managing your configs with git (which you probably should be), you really don’t want credentials committed to the repo.
There are two good approaches: environment files or Podman secrets. I’ll show you both.
Option 1: Environment Files (What I Use)
Remember that EnvironmentFile=/srv/containers/paperless-ngx/.env
line in the app quadlet? That’s where all the configuration lives.
Create a .env
file alongside your service’s data:
touch /srv/containers/paperless-ngx/.env
chmod 600 /srv/containers/paperless-ngx/.env
That chmod 600
is important - it makes the file readable only by you. No one else on the system can peek at your secrets.
Now put all your environment variables in there (this is what we referenced in the “localhost magic” section):
# /srv/containers/paperless-ngx/.env
USERMAP_UID=2000
USERMAP_GID=2000
PAPERLESS_REDIS=redis://127.0.0.1:6379
PAPERLESS_TIKA_ENABLED=1
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://127.0.0.1:3000
PAPERLESS_TIKA_ENDPOINT=http://127.0.0.1:9998
PAPERLESS_TIME_ZONE=Europe/Berlin
PAPERLESS_OCR_LANGUAGE=deu+eng
PAPERLESS_OCR_USER_ARGS={"invalidate_digital_signatures": true}
PAPERLESS_URL=https://paperless.example.com
PAPERLESS_MAIL_TASK_CRON=disable
PAPERLESS_ALLOWED_HOSTS=paperless.example.com
# Add sensitive stuff here when needed:
# PAPERLESS_SECRET_KEY=your-secret-key-here
# SMTP_PASSWORD=your-smtp-password
And that’s it - the quadlet file just references the .env
file with EnvironmentFile=/srv/containers/paperless-ngx/.env
. Much cleaner than inline environment variables, and your quadlet files can go in version control without worrying about leaking credentials.
Option 2: Podman Secrets (More Secure)
Podman has built-in secrets management that keeps credentials encrypted in its own store. It’s a bit more work to set up, but arguably more secure than text files.
Create secrets from the command line:
echo "your-secret-key-here" | podman secret create paperless_secret_key -
echo "your-smtp-password" | podman secret create paperless_smtp_password -
Then reference them in your quadlet file:
[Container]
ContainerName=paperless-ngx-app
Image=ghcr.io/paperless-ngx/paperless-ngx:latest
Pod=paperless-ngx.pod
AutoUpdate=registry
Secret=paperless_secret_key,type=env,target=PAPERLESS_SECRET_KEY
Secret=paperless_smtp_password,type=env,target=SMTP_PASSWORD
EnvironmentFile=/srv/containers/paperless-ngx/.env
Volume=/srv/containers/paperless-ngx/paperless-app/data:/usr/src/paperless/data:Z
...
The Secret=
directive tells Podman to inject that secret as an environment variable inside the container. The format is secret_name,type=env,target=ENV_VAR_NAME
.
You can still use EnvironmentFile=
for non-sensitive config alongside Secret=
for the actual credentials. Best of both worlds.
Why I stick with .env files: Honestly? It’s simpler for my use case. All my configs are in one place, backups are straightforward, and with proper permissions they’re secure enough for a home server. But if you’re running something more critical or want that extra layer of security, Podman secrets are the way to go.
What Goes Where?
Here’s my rule of thumb:
Put in .env
files or secrets:
- Passwords, API keys, tokens
- SMTP credentials
- Database connection strings with passwords
- Any URLs that might contain auth info
- Honestly, any config that might change between environments
Keep in quadlet files:
- Image names and versions
- Port mappings
- Volume mounts
- Container/pod names
- Service dependencies
Basically: secrets and config go in .env
or Podman secrets, infrastructure definitions stay in the quadlet.
Don’t Forget Permissions
If you’re using .env
files, always set them to 600
. I’ve seen too many setups where these files are world-readable because someone forgot that step. Quick way to accidentally expose credentials to anyone with shell access.
Quick check:
ls -la /srv/containers/*/.env
Everything should show -rw-------
. If not, fix it:
chmod 600 /srv/containers/*/.env
SELinux and the :Z
Suffix
I’m running Fedora with SELinux in enforcing mode. We already set up the base directory structure with proper SELinux contexts, but there’s one more critical piece: the :Z
suffix on volume mounts.
In your quadlet volume mounts, always use :Z
:
Volume=/srv/containers/paperless-ngx/paperless-redis:/data:Z
The :Z
tells Podman to:
- Relabel files with private SELinux labels for that specific container
- Apply MCS (Multi-Category Security) labels that isolate containers from each other
- Allow the container to actually access its files in enforcing mode
Without :Z
, you’ll get SELinux denials and containers won’t start properly in enforcing mode.
Check the labels after the container runs:
ls -Zd /srv/containers/paperless-ngx/paperless-app/media
You should see something like:
system_u:object_r:container_file_t:s0:c313,c1004 /srv/containers/paperless-ngx/paperless-app/media
Let’s break down what each part means:
system_u
- SELinux user (system_u for system processes)object_r
- SELinux role for files and objectscontainer_file_t
- SELinux type that allows container accesss0
- Sensitivity level (standard for most systems)c313,c1004
- MCS (Multi-Category Security) categories - these are the important bits!
Those c313,c1004
numbers are unique MCS categories assigned by Podman when you use :Z
. Each container gets its own unique pair, which prevents containers from accessing each other’s files even though they’re all running as the same user. It’s like giving each container its own private compartment within SELinux.
Verify SELinux is enforcing:
$ sestatus
Current mode: enforcing
With proper :Z
labels on all volume mounts, everything runs perfectly in enforcing mode - no compromises needed.
Why Quadlets Over Podman Compose?
You might be wondering why not just use podman compose
? It works with Docker Compose files and Podman supports it. A few reasons I prefer quadlets:
- Native systemd integration - Containers are real systemd services with proper dependencies, not a separate compose process
- Declarative and idempotent - Change the quadlet file, run
daemon-reload
, done. No separate up/down commands needed - Automatic enabling - The
[Install]
section handles boot startup without extra configuration - Better logging - Full integration with journald, not a separate logging setup
- Official Podman approach - Quadlets are the recommended way to run Podman with systemd
podman compose
is fine if you’re migrating from Docker or sharing compose files between environments. But for a server running Podman natively with systemd, quadlets feel cleaner and more integrated with the system.
Wrapping Up
So that’s how I’m running Paperless-NGX - 4 containers in a pod, managed by quadlet files, with SELinux running in enforcing mode.
The key takeaways:
- Organize with subdirectories - Keep related quadlets together in
~/.config/containers/systemd/
- Pods for multi-container apps - Shared localhost networking, managed as a unit
- Always use
:Z
- Critical for SELinux enforcing mode - Mix image versions - Pin infrastructure, float application containers
- Set up
/srv/containers
properly from the start with SELinux contexts
This setup has been running solid for months with zero manual intervention - the auto-update timer handles keeping everything current.