Deploy Django with Podman and Quadlet

Run Django in rootless Podman containers managed by systemd with Quadlet: container, network, and volume units, Podman secrets, health checks, and auto-updates.

Problem statement

Many teams want containers for reproducible builds but prefer systemd for process supervision, logging, and boot ordering, and do not want a root-owned Docker daemon on the server. Podman runs containers without a daemon and can run them as an unprivileged user. Quadlet, built into Podman, turns small declarative files into systemd services, so each container is started, restarted, and logged like any other unit.

Getting this right for Django means a few decisions: where the units live (rootless or rootful), how containers find each other, where secrets come from, how Nginx reaches the app, and how releases and migrations happen. This guide sets up a rootless Django and PostgreSQL deployment with Quadlet.

Django with Podman QuadletQuadlet files.container, .networksystemdgenerated servicesPodmanrootless containersHost NginxTLS, 127.0.0.1:8000Quadlet generates systemd services, so journalctl, restarts, and boot order work as usual.
  1. Create an unprivileged user and enable lingering so its services run without a login session.
  2. Put Quadlet files in ~/.config/containers/systemd/: a .network, a .volume, and .container units for PostgreSQL and Django.
  3. Store secrets with podman secret create and inject them with Secret=...,type=env.
  4. Publish Django only on 127.0.0.1:8000 and put host Nginx in front for TLS.
  5. systemctl --user daemon-reload, then start the generated services.
  6. Release by pulling a new tag, running migrations in a one-off container, and restarting the web service.

Step-by-step solution

1. Prepare a rootless user

sudo useradd --create-home --shell /bin/bash myapp
sudo loginctl enable-linger myapp
sudo -iu myapp
podman --version
a rootless user that survives logoutDedicated system userno sudo, no passwordsubuid and subgid rangesfor user namespacesloginctl enable-lingerservices run without a loginUnits in ~/.config/containers/systemdmanaged with systemctl --userWithout linger, every container stops when the SSH session endsdjango-deployment.com
A container escape then lands in an unprivileged account, not root.

Lingering lets the user's systemd instance start at boot and keep running after logout. Use Podman 4.4 or newer for Quadlet; newer releases add more unit options, so check man podman-systemd.unit on your server for what your version supports.

When managing the user's services over SSH, log in as that user directly (or use machinectl shell myapp@) so systemctl --user can reach the user's bus.

2. Create secrets

python3 -c "import secrets; print(secrets.token_urlsafe(50), end='')" | podman secret create django_secret_key -
openssl rand -base64 32 | tr -d '\n' | podman secret create postgres_password -
podman secret ls

3. Define the network and volume

# ~/.config/containers/systemd/myapp.network
[Network]
# ~/.config/containers/systemd/pgdata.volume
[Volume]

Quadlet creates a network named systemd-myapp and a volume named systemd-pgdata. Containers on the same network resolve each other by container name.

4. Define PostgreSQL

# ~/.config/containers/systemd/myapp-db.container
[Unit]
Description=PostgreSQL for myapp

[Container]
Image=docker.io/library/postgres:16
ContainerName=myapp-db
Network=myapp.network
Volume=pgdata.volume:/var/lib/postgresql/data
Environment=POSTGRES_DB=myapp POSTGRES_USER=myapp
Secret=postgres_password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U myapp -d myapp
HealthInterval=10s

[Service]
Restart=always

[Install]
WantedBy=default.target

5. Define the Django container

# ~/.config/containers/systemd/myapp-web.container
[Unit]
Description=myapp Django (Gunicorn)
Requires=myapp-db.service
After=myapp-db.service

[Container]
Image=ghcr.io/example/myapp:1.4.0
ContainerName=myapp-web
Network=myapp.network
PublishPort=127.0.0.1:8000:8000
Environment=DJANGO_SETTINGS_MODULE=config.settings.production
Environment=DJANGO_ALLOWED_HOSTS=example.com
Environment=DATABASE_HOST=myapp-db
Secret=django_secret_key,type=env,target=DJANGO_SECRET_KEY
Secret=postgres_password,type=env,target=DATABASE_PASSWORD
Exec=gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --access-logfile -
HealthCmd=python -c "import urllib.request as u; u.urlopen(u.Request('http://127.0.0.1:8000/healthz/', headers={'Host': 'example.com'}), timeout=3)"
HealthInterval=15s

[Service]
Restart=always
TimeoutStartSec=120

[Install]
WantedBy=default.target
a container defined as a systemd unitImage=ghcr.io/example/myapp:1.4.0Network=myapp.networkPublishPort=127.0.0.1:8000:8000Secret=django_secret_key,type=env,target=DJANGO_SECRET_KEYHealthCmd=python -c "…/healthz/…"Restart=always12341A pinned tag; releasing means editing this line.2Only Nginx on the host can reach Gunicorn.3A Podman secret exposed as an environment variable.4systemd restarts the container like any service.systemctl --user daemon-reload turns this file into myapp-web.servicedjango-deployment.com
The database container is reached by its name on the shared network.

Pin an exact image tag. Requires= and After= refer to the generated service names, which match the file names with .service.

6. Start the services

systemctl --user daemon-reload
systemctl --user start myapp-db.service
systemctl --user start myapp-web.service
systemctl --user status myapp-web.service --no-pager
podman ps

Generated units cannot be enabled with systemctl enable; the [Install] section in the Quadlet file takes care of starting them at boot. If a unit does not appear after daemon-reload, check the generator output:

/usr/libexec/podman/quadlet -dryrun -user

7. Run migrations

Migrations run once per release in a one-off container on the same network:

podman run --rm --network systemd-myapp \
  --secret django_secret_key,type=env,target=DJANGO_SECRET_KEY \
  --secret postgres_password,type=env,target=DATABASE_PASSWORD \
  -e DJANGO_SETTINGS_MODULE=config.settings.production \
  -e DATABASE_HOST=myapp-db \
  ghcr.io/example/myapp:1.4.0 python manage.py migrate --noinput

8. Put Nginx in front

Rootless containers cannot bind ports below 1024 by default, which is why the app is published on localhost port 8000. Host Nginx (installed normally, as root) terminates TLS:

server {
    listen 443 ssl;
    server_name example.com;
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Set SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") in Django. On SELinux systems, Nginx needs setsebool -P httpd_can_network_connect 1 to reach the published port.

9. Release a new version

sed -i 's|myapp:1.4.0|myapp:1.5.0|' ~/.config/containers/systemd/myapp-web.container
podman pull ghcr.io/example/myapp:1.5.0
# run the migration command from step 7 with the new tag
systemctl --user daemon-reload
systemctl --user restart myapp-web.service

Verification check:

systemctl --user is-active myapp-web.service
podman healthcheck run myapp-web && echo healthy
journalctl --user -u myapp-web.service -n 50 --no-pager
curl -sI https://example.com/ | head -1
a release with Quadlet1podman pull the new tagbefore touching the unit2Edit Image= in the unitone line3daemon-reloadregenerate the service4Run migrationspodman run --rm, new image5Restart and check healthpodman healthcheck runRolling back is the same steps with the previous tagdjango-deployment.com
Pulling first keeps the restart short: nothing is downloaded while the site is down.

Explanation

Quadlet is a systemd generator: on every daemon-reload, it reads the .container, .network, and .volume files and writes equivalent service units that call podman run with the right flags. From then on, systemd handles ordering, restarts, and logging, and the container runs under an unprivileged user with no central daemon. The result looks like a classic systemd deployment to operators while keeping the build reproducibility of container images.

Edge cases / notes

Automatic updates

Adding AutoUpdate=registry to the [Container] section and enabling podman-auto-update.timer makes Podman pull newer images for the same tag and restart the service. That suits mutable tags like stable, but it skips your migration step, so use it only when migrations run separately or the release never includes them.

Volume permissions

In rootless mode, container UIDs map to subordinate UIDs on the host, so files in bind mounts may appear owned by unexpected numeric IDs. Prefer named volumes, or use :U on a bind mount to chown it to the container user. On SELinux hosts, add :Z to bind mounts so they get a private label.

Rollback

Change the tag back to the previous version, reload, and restart. Keep migrations backward compatible so the previous image still works with the migrated schema.

When to automate this

The release steps (edit tag, pull, migrate, reload, restart, health check) fit in a short script or an Ansible role that templates the Quadlet files. Keeping the Quadlet files in the repository makes the server configuration reviewable alongside the code.

Next steps

FAQ

What is the difference between Quadlet and podman generate systemd?

podman generate systemd produced unit files from existing containers and is deprecated. Quadlet uses declarative files that are converted to units on every reload, so the files are the source of truth.

Can rootless Podman serve Django on port 443?

Not directly by default, because unprivileged users cannot bind ports below 1024. Put a host reverse proxy in front, or lower net.ipv4.ip_unprivileged_port_start if you accept that trade-off.

How do containers find each other with Quadlet?

Put them on the same .network unit and refer to each other by ContainerName. Podman's DNS resolves container names on user-defined networks.

Why doesn't my Quadlet service start at boot?

For rootless units, the user needs lingering enabled with loginctl enable-linger, and the Quadlet file needs an [Install] section with WantedBy=default.target.

Keep going