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.
- Create an unprivileged user and enable lingering so its services run without a login session.
- Put Quadlet files in
~/.config/containers/systemd/: a.network, a.volume, and.containerunits for PostgreSQL and Django. - Store secrets with
podman secret createand inject them withSecret=...,type=env. - Publish Django only on
127.0.0.1:8000and put host Nginx in front for TLS. systemctl --user daemon-reload, then start the generated services.- 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
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
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
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
- Build the image with How to Dockerize a Django App for Production.
- Compare with a non-container unit in How to Configure systemd for Gunicorn and Django.
- Tune the proxy with How to Configure Nginx for Django in Production.
- If containers cannot write to mounted directories, see Fix Permission Denied on Docker Volumes for Django.
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.