Move from docker-compose to quadlet


Why do this?

For the applications I run, a full Kubernetes setup would be overkill, but I want something more robust than Podman Compose. I think Quadlets is a sweet spot for ease of use and reliability.

Quadlets integrate with systemd to automatically start and restart containers, giving me the reliability I need without the complexity. So far this setup has been very reliable for me.

My typical workflow starts with a Docker Compose file for quick testing and development. Once I’m ready for something long running, I convert it to a quadlet.

What am I doing

This guide walks through converting a Docker Compose service to a Podman Quadlet with systemd integration. I’ll cover authentication with GitHub Container Registry and set up user-specific services that start automatically on boot.

Starting Docker Compose File

This is a dummy compose file. It’s very similar to what I usually use for simple Python apps.

services:
  my_container:
    image: my_container:latest
    ports:
      - "8501:8501"
    volumes:
      - /home/ajones/Data:/app/data
    environment:
      - PYTHONUNBUFFERED=1
    restart: unless-stopped

Create the Quadlet File

Create the directory and file:

mkdir -p ~/.config/containers/systemd

Create and open the container file.

 nano ~/.config/containers/systemd/my_container.container

Paste in the the following data

[Container]
Image=ghcr.io/alejones/my_container:latest
AutoUpdate=registry
PublishPort=8501:8501
Volume=%h/Data:/app/Data
Environment=PYTHONUNBUFFERED=1

[Service]
Restart=always

[Install]
WantedBy=default.target

Notes

  • Use %h instead of absolute paths like /home/username/ for portability
  • AutoUpdate=registry enables automatic image updates with podman auto-update

Key Differences

Docker ComposeQuadlet
portsPublishPort
volumesVolume (one per line)
environmentEnvironment
restartHandled by [Service] section
Image updatesAutoUpdate=registry

Enable User Lingering

Allow your user services to run without being logged in:

sudo loginctl enable-linger $USER

Verify lingering is enabled:

loginctl show-user $USER | grep Linger

You should get an output like this

ajones@vm1:~$ loginctl show-user $USER | grep Linger
Linger=yes

Authenticate with GitHub Container Registry

Create a GitHub Personal Access Token with read:packages scope at: GitHub → Settings → Developer settings → Personal access tokens

Login to GHCR:

podman login ghcr.io

Use your GitHub username and the Access Token as the password.

Deploy the Service

Reload systemd and start the service:

systemctl --user daemon-reload
systemctl --user start my_container.service

The service should auto-enable due to WantedBy=default.target in the quadlet. Verify it’s enabled:

systemctl --user is-enabled my_container.service

You should see

ajones@vm1:~$ systemctl --user is-enabled my_container.service
generated

Updating Images

Check for and apply image updates:

podman auto-update

This pulls newer images and restarts containers that have AutoUpdate=registry set.

If there is an update, you’ll see the container getting pulled and a note that it has been updated.

podman auto-update
Trying to pull ghcr.io/alejones/myRepo/my_container:latest...
Getting image source signatures
Copying blob 8045c9806d81 skipped: already exists  
Copying blob 49ccfcf26a76 skipped: already exists  
Copying blob 61320b01ae5e skipped: already exists  
Copying blob 7a1cb8b88221 skipped: already exists  
Copying blob 6a3674a456ea skipped: already exists  
Copying blob 8991c9200d62 skipped: already exists  
Copying blob 157fcaa91dcb skipped: already exists  
Copying blob 88feadc186aa skipped: already exists  
Copying blob f15096e3c9ed skipped: already exists  
Copying blob 2505926d570d done   | 
Copying blob be1274d3cce0 skipped: already exists  
Copying config 47e63d279d done   | 
Writing manifest to image destination
            UNIT                         CONTAINER                                   IMAGE                                                   POLICY      UPDATED
            mailpiece-annotator.service  cb3948b23fb9 (systemd-mailpiece-annotator)  ghcr.io/alejones/someContainer:latest           registry    false
            slm-testing.service          7f427f80ba98 (systemd-slm-testing)          ghcr.io/alejones/myRepo/my_container:latest  registry    true

Did it work?

You don’t need to do any of these, but they might be helpful if you are running into trouble.

Check that the container is running

podman ps

Check the status:

systemctl --user status my_container.service

You should get response like this. I’m running a streamlit app, the output will change depending on your container.

ajones@vm1:~$ systemctl --user status my_container.service
 my_container.service
     Loaded: loaded (/home/ajones/.config/containers/systemd/my_container.container; generated)
     Active: active (running) since Fri 2025-05-30 17:27:39 CDT; 19min ago
   Main PID: 19876 (conmon)
      Tasks: 38 (limit: 76532)
     Memory: 217.0M (peak: 221.0M)
        CPU: 4.737s
     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/my_container.service
             ├─libpod-payload-cb3948b23fb910ac512f13510c101a798129a77225e3b2fe4be19afdbbb9b055
 └─19888 /usr/local/bin/python3 /usr/local/bin/streamlit run app.py --server.address=0.0.0.0 --server.po>
             └─runtime
               ├─19854 /usr/bin/slirp4netns --disable-host-loopback --mtu=65520 --enable-sandbox --enable-seccomp --en>
               ├─19856 rootlessport
               ├─19862 rootlessport-child
               └─19876 /usr/bin/conmon --api-version 1 -c cb3948b23fb910ac512f13510c101a798129a77225e3b2fe4be19afdbbb9>

May 30 17:27:39 vm1 podman[19834]: 2025-05-30 17:27:39.633683542 -0500 CDT m=+0.020642742 image pull 1a42d82c72d>
May 30 17:27:39 vm1 podman[19834]: 2025-05-30 17:27:39.812141436 -0500 CDT m=+0.199100612 container init cb3948b>
May 30 17:27:39 vm1 podman[19834]: 2025-05-30 17:27:39.818534127 -0500 CDT m=+0.205493308 container start cb3948>
May 30 17:27:39 vm1 systemd[3138]: Started my_container.service.
May 30 17:27:39 vm1 my_container[19834]: cb3948b23fb910ac512f13510c101a798129a77225e3b2fe4be19afdbbb9b055
May 30 17:27:40 vm1 systemd-my_container[19876]: 
May 30 17:27:40 vm1 systemd-my_container[19876]:   You can now view your Streamlit app in your browser.
May 30 17:27:40 vm1 systemd-my_container[19876]: 
May 30 17:27:40 vm1 systemd-my_container[19876]:   URL: http://0.0.0.0:8501
May 30 17:27:40 vm1 systemd-my_container[19876]: 

View logs:

journalctl --user -u my_container.service -f

Manage the Service

Stop the service:

systemctl --user stop my_container.service

Restart the service:

systemctl --user restart my_container.service

Disable the service:

systemctl --user disable my_container.service

Next thing I want to try

Podlet is a tool to automatically turn compose files into quadlets. These are my quick notes to try later. Follow at your own peril.

Podlet handles many of the tedious conversion details automatically and can generate multiple related files at once. It’s especially useful for complex setups with multiple containers, networks, and volumes.

Convert Compose to Quadlet

# Convert your compose file directly
podlet compose docker-compose.yml --file ~/.config/containers/systemd/

# Or pipe a command to create a quadlet
podlet podman run --name my-app -p 8501:8501 my-image:latest