Podman: the good, the bad

Containers, containers, containers.

Not the steve guy from microsoft

TLDR>

podman is worth the effort, just.

Why Podman?

I was spending a lot of time deploying manually. I liked the idea of one, independent ‘box’ with everything in it. Why not Docker: more a personal preference. Google for real reasons like security, integration, daemonless.

Which Podman?

It’s a pain, but Podman v5 is not available on Ubuntu 22. Podman v4 can be installed and is used here.

I used podman-compose (seperate python in v4). See deets below.

The tools

podman
The key tool. You manage a local store of images, containers, etc. I installed globally on my system.

podman-compose

A python library which takes a YAML file and runs podman commands. Easier than running directly.

The options

Requirements are:

  • self contained, with db, django, django-q seperately startable
  • ideally on a container network with a single entry (e.g. 8000)
  • debuggable
  • not a pain in the ass

Notes of experiments

  • podman-compose is helpful
  • pods are good as they enable easy networking between containers.
  • systemd was the most difficult to be working. See gotcha.

Solution

I settled in the following:

  • A pod with containers for postgres (stock image), mydjango (custom image), mydjango-q (same image)
  • entrypoint.sh in mydjango image waits for the database, runs migrations and gunicorn
  • All setup goes in mydjango-compose.yml which podman-compose uses. There is a nice ‘overlay’ system so you can add mydjango-compose-debugging.yml which just changes a few things to enable debugging (see example).

Steps

Create a Containerfile (example below) to generate the Django app:

See example below. Note entrypoint.sh for managing Django specifics.

Use podman build to generate an image. Tag it if you like with -t. Note the sudo as we will use this in systemd root (not user).

My understanding is that the built image is the thing that we care about: it is a self-contained application. We can adjust settings when we run it via environment vars, mounts and entry point command. But that’s it: it’s read-only.

sudo podman build -f Containerfile .

Start the pod manually and test:

podman-compose -f podman-compose.yml --in-pod up
podman ps # see the containers
podman pod ls # see one pod
podman exec -it <container> bash # shell in and check
podman logs <logs> # check logs

Install the systemd file at /etc/systemd/system<yourapp>-pod.service (see example) then:

sudo systemctl daemon-reload
sudo systemctl restart <yourapp>-pod.service
journalctl  -f -u <yourapp>-pod.service 

Finally, you should be an open port at 8000 on your host machine. Browse it!

In this arrangement, you’d setup nginx seperately to reverse-proxy 80 -> 8000 and manage certificates.

Gotchas

Understand the different terms like image, container, pod.

podman has a seperate rootand user repo. So sudo podman ps will be different to podman ps

Networking can be tricky. Use podman exec -it <container> bash to shell into container and check /etc/hosts, if you can access other containers.

Common images are really bare bones. Consider adding iputils-ping, net-tools, dnsutils. See example.

Systemd integration is hard:

  • Using systemctl –user (getting service files from .config/…) is good in theory. You can run as local user. Expose a port (e.g. 8000) and have a root process (e.g. ngnix) as a proxy. However, there are various problems:
    • networking in rootless mode is different
    • raft of XDG and podman user-socket errors
    • problems running from systemd compared to command line
    • need ‘linger’ settings
    • systemctl needs ‘–user’ to see them
  • Using systemctl (root) is less security but easier.
  • You can’t use [Pod] in systemd files and Quadlets unless you have v5.

Examples

Containerfile: Django Application


# ── builder stage ─────────────────────────────────────────────────────────────
FROM python:3.12-slim AS builder

# system deps for psycopg2 / build tools
RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      build-essential \
      libpq-dev \
      iproute2 \          
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# install Poetry and project dependencies into the image
COPY pyproject.toml poetry.lock ./
RUN pip install "poetry>=1.8" \
 && poetry config virtualenvs.create false \
 && poetry install --no-root --no-interaction --no-ansi

# ── final stage ───────────────────────────────────────────────────────────────
FROM python:3.12-slim

# runtime deps
RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      libpq5 \
    postgresql-client \
       iputils-ping \
      net-tools \
      dnsutils \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# copy installed packages code
COPY --from=builder /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/
# copy installed scripts
COPY --from=builder /usr/local/bin/ /usr/local/bin/

# copy your source (.containerignore excludes .venv, .git, etc)
COPY . .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENV PYTHONUNBUFFERED=1

# collect static, migrate, etc
ENTRYPOINT ["/entrypoint.sh"]

EXPOSE 8000

CMD ["gunicorn", "-c", "gunicorn.conf.py", "wayserver.wsgi:application"]

podman-compose.yml

services:
  db:
    image: docker.io/library/postgres:15-alpine
    hostname: db
    restart: always
    volumes:
      - pgdata:/var/lib/postgresql/data:Z # the :Z is for SELinux labeling
    env_file:
      - .env.development
    environment:
      POSTGRES_USER: "${DB_USER}"
      POSTGRES_PASSWORD: "${DB_PASSWORD}"
      POSTGRES_DB: "${DB_NAME}"

  web:
    build:
      context: .
      dockerfile: Containerfile
    restart: always
    ports:
      - "8000:8000"
    depends_on:
      - db
    env_file:
      - .env.development
    environment:
      - DB_HOST=db #  points to db container
      - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} # note 'db' is db container's hostname

volumes:
  pgdata:

mydjango-compose-debug.yml

Use with podman-compose like:

podman-compose -f base.yml -f debug.yml

To debug, start the pod (i.e. podman-compose up…) and connect with a remote debugger (e.g. python in vscode) from the same folder (so it can see the source files) to port 5678.

version: "3.8"

services:
  web:
    image: localhost/wayserver_web:latest
    # map your source code to inside the container
    # you can also do this will just key folders
    bolumes:
    - .:/app:Z
    ports:
      - "5678:5678"
    env_file:
      - .env.development

    command: >
      sh -c "
        python -m debugpy --listen 0.0.0.0:5678 --wait-for-client \
          manage.py runserver 0.0.0.0:8000
      "

entrypoint.sh

#!/usr/bin/env sh
#set -e

echo "Waiting for Postgres at ${DB_HOST}:${DB_PORT}…"
until pg_isready -h "$DB_HOST" -p "$DB_PORT" > /dev/null 2>&1; do
  printf '.'
  sleep 1
done
echo "Postgres is up"
env

# apply migrations & collect static
python manage.py migrate --noinput
python manage.py collectstatic --noinput

# Only load initial_data if the DB is empty (e.g. no users yet)
if [ "$(python manage.py shell -c "from django.contrib.auth import get_user_model; print(get_user_model().objects.exists())")" = "False" ]; then
   echo "Loading initial fixture"
   python manage.py loaddata /app/fixtures/2025-07-08-dumpdata.json
   echo "Fixture loaded"
 else
   echo "Skipping initial fixture load, users already exist"
 fi


# then exec the main process
# use $@ to enable substitution of the defailt
# typically this is gunicorn...
exec "$@" # run the CMD passed to the container

systemd service file

This could be created podman-compose systemd -a create-unit but I chose to do it manually as I didn’t understand how that tool worked.

[Install]
WantedBy=multi-user.target

[Unit]
Description=WayServer Pod (root)
After=network-online.target podman.socket
Wants=network-online.target podman.socket

[Service]
Type=simple

# Should contain podman-compose.yml and .env
WorkingDirectory=/home/xxxxx/xxxxx/

ExecStart=podman-compose  -f podman-compose.yml --in-pod=true up --build
ExecStop=podman-compose down

# Auto-restart if the compose wrapper crashes
Restart=on-failure
RestartSec=60

Makefile


pod:  ## Restart the podman container 
	pc down && pc -f podman-compose.yml up -d
pod-debug:  ## Restart the podman container
	pc down && pc -f podman-compose.yml -f podman-compose.development.yml -f podman-compose.debug.yml up -d
pod-development:  ## Restart the podman container in development mode
	pc down && pc -f podman-compose.yml -f podman-compose.development.yml up -d
pod-production:  ## Restart the podman container in production mode
	pc down && pc -f podman-compose.yml -f podman-compose.production.yml up -d

pod-build:
	sudo podman build -t wayserver:latest -f Containerfile .


pod-deploy: ## Deploy the app as a podman pod
	sudo mkdir -p /etc/wayserver
	sudo chmod 755 /etc/wayserver
	sudo cp .env /etc/wayserver/.env
	sudo cp podman-compose.yml /etc/wayserver/
	sudo cp podman-compose.production.yml /etc/wayserver/
	sudo cp deploy/wayserver-pod.service /etc/systemd/system/
	sudo systemctl daemon-reload
	#sudo systemctl restart wayserver-pod.service
	#journalctl -f -u wayserver-pod.service

Leave a Reply

Your email address will not be published. Required fields are marked *