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:

  1. Create the pod
  2. Create all 4 containers
  3. Link them to the pod
  4. Set up dependencies
  5. 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:

  1. Relabel files with private SELinux labels for that specific container
  2. Apply MCS (Multi-Category Security) labels that isolate containers from each other
  3. 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 objects
  • container_file_t - SELinux type that allows container access
  • s0 - 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.


Resources