If you run containers on a Linux server, you may want them to fit alongside the services already running there: start at boot, appear in the same status and log tools, and respect startup dependencies. Project READMEs often provide docker run or Compose instructions. I’ll show how I turn those instructions into Quadlets so systemd can manage the containers.
Podman is an open-source container engine. If you have used Docker, much will feel familiar: you can pull an image, attach storage, publish a port, and start a container with podman run much as you would with docker run. It also lets you manage containers under an ordinary user account, reducing the need for host-wide privileges; I’ll return to that option later.
You have probably also encountered systemd, the service manager on many Linux systems. It starts host services at boot, keeps track of their state, and can restart them after a failure. You control its services with systemctl and read their logs with journalctl. Docker’s daemon may itself be a systemd service, but Docker usually manages individual containers. Podman doesn’t need a central daemon. With Quadlet, systemd manages each container as its own service, so the service manager you already have handles startup, restarts, and dependencies.
A Quadlet is a small configuration file that describes a Podman container or another resource. For example, wp-db.container describes a container. Podman’s Quadlet generator turns it into wp-db.service when systemd loads its units.
In this article, I assume basic familiarity with Docker concepts such as images, containers, and volumes. I’ll explain the Podman and service-management pieces as the examples need them.
Read the source before translating it
A docker run command or Compose file contains the core information needed to run an application: its images, settings, storage, ports, and connections between containers. Quadlet uses those same facts, but records them in its own files for systemd to operate.
Three Quadlet file types matter here.
- A
.containerfile describes one container, including its image, environment, mounts, and published ports. - A
.networkfile describes a network that containers can share. - A
.volumefile describes a named volume for data that survives container replacement.
A container file can refer to network and volume files so Quadlet prepares those resources before starting the container.
The Docker formats package this information differently. docker run puts it in one command; Compose groups services and shared resources in YAML.
The translation starts by taking the Docker instructions apart: which settings belong to each container, and which networks and volumes do they share? Then I’ll build the .container files and add .network and .volume files where the application needs them. The first example is a single docker run command.
One docker run command
I’m using a simplified MariaDB example to show how the translation works. The command is adapted from the MariaDB image documentation.
docker run --name wp-db \
--mount type=volume,source=db-data,target=/var/lib/mysql \
--mount type=bind,source=/srv/mariadb/db-root-password,target=/run/secrets/db-root-password,readonly \
--mount type=bind,source=/srv/mariadb/db-app-password,target=/run/secrets/db-app-password,readonly \
-e MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-root-password \
-e MARIADB_DATABASE=wordpress \
-e MARIADB_USER=wordpress \
-e MARIADB_PASSWORD_FILE=/run/secrets/db-app-password \
docker.io/library/mariadb:11.4.13
Assume the two password files already exist under /srv/mariadb/, are private to the administrator, and contain different passwords.
Read the command once before writing a Quadlet. The final argument is the MariaDB image; --mount supplies a data volume and two read-only password files; -e sets the database configuration. There is no -p option, so the command does not publish a port on the host. These are the facts I’ll carry into the translation.
Quadlet files group Key=Value settings under headings in square brackets. The files below will define the database volume and container, and ask systemd to start the service when the machine boots.
The password files are bind-mounted in the Docker command. That gives me a choice: preserve those mounts or let Podman manage them as secrets. I’ll use Podman secrets to deliver the same password contents where MariaDB expects them. First, create the secrets from the private files:
sudo podman secret create db-root-password /srv/mariadb/db-root-password
sudo podman secret create db-app-password /srv/mariadb/db-app-password
Keep the password contents out of shell arguments and the repository. The Quadlet’s Secret= settings below mount the secrets at /run/secrets/<name>, the paths MariaDB already reads. Here are the resulting Quadlet files under /etc/containers/systemd/.
db-data.volume:
[Volume]
wp-db.container:
[Container]
Image=docker.io/library/mariadb:11.4.13
ContainerName=wp-db
Volume=db-data.volume:/var/lib/mysql
Secret=db-root-password
Secret=db-app-password
Environment=MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-root-password
Environment=MARIADB_DATABASE=wordpress
Environment=MARIADB_USER=wordpress
Environment=MARIADB_PASSWORD_FILE=/run/secrets/db-app-password
[Install]
WantedBy=multi-user.target
Compare the two Quadlet files with the Docker command: [Volume] defines persistent storage, while [Container] holds the image, name, environment, and volume attachment. Podman manages the database data; /srv/mariadb/ holds the password files the administrator needs to access directly. wp-db.container generates wp-db.service, and its reference to db-data.volume arranges for the volume to exist first. Podman names it systemd-db-data by default. The [Install] section requests startup at boot. Only other containers need the database, so there is no published host port.
MariaDB uses those password settings to initialize a new data directory. To rotate a password in an existing database, change the account in MariaDB and update the corresponding Podman secret; changing the initial settings alone leaves the existing account unchanged.
These are the direct mappings from the command above:
| Docker instruction | Quadlet setting |
|---|---|
Image at the end of docker run |
Image=docker.io/library/mariadb:11.4.13 |
--name wp-db |
ContainerName=wp-db |
-e MARIADB_DATABASE=wordpress |
Environment=MARIADB_DATABASE=wordpress |
Mounts call for a choice about who manages the files: MariaDB’s data lives in a named volume, while the Caddy configuration later uses a host bind mount so the administrator can edit it. For other flags, consult the Quadlet .container reference as you encounter them.
From compose.yml to several Quadlets
Now add a web application. This abbreviated Compose example is adapted from the WordPress image documentation to use the MariaDB image above. The same private application-password file is provided to both services; only MariaDB receives the root-password file. The initial port is deliberately bound to the host’s loopback interface:
services:
wp-db:
image: docker.io/library/mariadb:11.4.13
environment:
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db-root-password
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD_FILE: /run/secrets/db-app-password
secrets: [db-root-password, db-app-password]
volumes: [db-data:/var/lib/mysql]
networks: [app]
wordpress:
image: docker.io/library/wordpress:7.1.3-php8.3-apache
environment:
WORDPRESS_DB_HOST: wp-db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db-app-password
WORDPRESS_DB_NAME: wordpress
secrets: [db-app-password]
volumes: [wp-data:/var/www/html]
networks: [ingress, app]
ports: ["127.0.0.1:8080:80"]
depends_on: [wp-db]
networks:
ingress: {}
app: {}
volumes:
db-data: {}
wp-data: {}
secrets:
db-root-password:
file: /srv/mariadb/db-root-password
db-app-password:
file: /srv/mariadb/db-app-password
Before writing Quadlets, trace how these two services fit together. WordPress uses wp-db as its database host because both services share the app network. Both need the application-password secret, but only MariaDB needs the root-password secret. Each has its own data volume. WordPress also joins ingress, although for now its port is reachable only from the host itself. Finally, depends_on says to start MariaDB before WordPress; it says nothing about when MariaDB can answer queries.
Compose groups those connections into one project. With Quadlets, I’ll define the networks and volumes as separate resources, give the containers the secrets they need, and connect each container to the right resources.
Each of the network definition files app.network and ingress.network contains only:
[Network]
Similarly, for the WordPress data volume in wp-data.volume:
[Volume]
Add Network=app.network to the earlier wp-db.container. Its ContainerName=wp-db makes the intended database hostname explicit on that network. Then add wordpress.container:
[Unit]
Requires=wp-db.service
After=wp-db.service
[Container]
Image=docker.io/library/wordpress:7.1.3-php8.3-apache
ContainerName=wordpress
Network=ingress.network
Network=app.network
Volume=wp-data.volume:/var/www/html
Secret=db-app-password
Environment=WORDPRESS_DB_HOST=wp-db
Environment=WORDPRESS_DB_USER=wordpress
Environment=WORDPRESS_DB_PASSWORD_FILE=/run/secrets/db-app-password
Environment=WORDPRESS_DB_NAME=wordpress
PublishPort=127.0.0.1:8080:80
[Install]
WantedBy=multi-user.target
The two Network= lines put WordPress on both container networks. wp-db stays only on app.network. By default, app.network creates a Podman network named systemd-app; the .network reference also gives the containers a dependency on its generated service. wp-data.volume is a new Podman volume, not an import of the Compose project’s existing data. Plan a data migration separately if you are replacing a running Compose deployment.
Requires= and After= start the database service before WordPress, but MariaDB may still be initializing. In this example, request the WordPress setup page after startup and check MariaDB’s journal if WordPress cannot connect. If WordPress must wait automatically, give MariaDB a HealthCmd= that tests a database connection and set Notify=healthy so systemd waits for that check before starting WordPress. Compose’s short depends_on likewise orders starts; its service_healthy form also needs a health check.
Other Compose inputs call for their own decisions. A .env file can supply ${VARIABLE} values when Compose reads the YAML; check environment and env_file to see what actually reaches each container. Profiles, builds, and secrets also need deliberate translation rather than a mechanical key swap.
Put Caddy on the public edge
The loopback port above lets me test WordPress from the host. For a public site, which container should accept internet traffic, and how much of the application stack should it be able to reach? Caddy can handle the public hostname and HTTPS, then forward requests to WordPress. Separating the networks is a security choice: it limits the public-facing proxy’s direct access. Caddy needs a connection to WordPress, while WordPress needs a connection to MariaDB. Those are two different paths: Caddy and WordPress share ingress.network; WordPress and MariaDB share app.network.
This keeps Caddy off the database network. WordPress joins both networks because it receives web requests on one and makes database requests on the other. Remove PublishPort=127.0.0.1:8080:80 from wordpress.container so Caddy is the only container with a host-published port:
internet → Caddy [ingress] → WordPress [ingress, app] → MariaDB [app]
Caddy joins ingress.network only. It sends requests to WordPress’s container name and Apache port, wordpress:80, on their shared network. localhost inside Caddy would mean the Caddy container itself, not WordPress. Neither WordPress nor MariaDB needs a published host port.
A Caddyfile for an owned public hostname can be as small as this:
blog.example.com {
reverse_proxy wordpress:80
}
blog.example.com is a placeholder; replace it with a domain you control. Save this file as /srv/caddy/config/Caddyfile. The Caddy Quadlet bind-mounts that administrator-managed configuration directory, publishes the HTTP and HTTPS ports, and keeps certificate state in a Podman-managed volume.
caddy-data.volume:
[Volume]
caddy.container:
[Container]
Image=docker.io/library/caddy:2.11.7
Network=ingress.network
Volume=/srv/caddy/config:/etc/caddy:ro,Z
Volume=caddy-data.volume:/data
PublishPort=80:80
PublishPort=443:443
[Install]
WantedBy=multi-user.target
Only Caddy has host-published ports. On this rootful bridge setup, Podman configures forwarding for them. If the host uses firewalld, its default StrictForwardPorts=no setting can make a Podman-published port reachable even when the interface zone’s allowed-services list omits it. Check the effective forwarding policy of the installed Podman/firewall setup. From clients outside the host, test that Caddy answers on the intended public addresses and that addresses meant to remain private are unreachable, including IPv6 where used. If an unintended address answers, bind the published port to the appropriate host IP where possible or change the forwarding policy, then test again. WordPress joins both container networks, so the network split reduces direct access to MariaDB rather than completely isolating it.
For Caddy’s automatic HTTPS to work, the public hostname must resolve to this host, incoming traffic must reach ports 80 and 443, and Caddy must retain its certificate data. The :Z bind-mount label is for a private mount on an SELinux host; check permissions and labeling for the actual host. Secure the WordPress admin account, keep the images updated, and back up the database and WordPress data.
Operate the generated services
Two user accounts matter here. The host account runs Podman through its systemd manager; the in-container user runs the application. An image supplies the in-container user by default, and [Container] User= can select a different one. To choose the host account, choose between a system Quadlet and a user Quadlet.
On Linux, host ports below 1024 are privileged ports: an ordinary host account cannot normally bind them. HTTP and HTTPS conventionally use ports 80 and 443, so rootless Podman cannot normally publish Caddy on those host ports. An administrator could forward public 80 and 443 to higher rootless ports, but that adds host-level ingress configuration to maintain. For this connected stack, I recommend rootful system Quadlets under /etc/containers/systemd/: Podman runs as host root, and Caddy publishes 80 and 443 directly.
Rootless Podman limits the container’s host privileges. Its Quadlets belong in an ordinary account’s ~/.config/containers/systemd/ and are managed with that account’s systemctl --user. That account needs access to bind-mounted files and lingering if its services must start without a login session.
Reload the system manager to pick up the example files, then start the containers and check their status and logs:
sudo systemctl daemon-reload
sudo systemctl start wp-db.service wordpress.service caddy.service
sudo systemctl status wordpress.service
sudo journalctl -u wordpress.service -b
sudo journalctl -u wp-db.service -b
sudo journalctl -u caddy.service -b
After substituting your real hostname for blog.example.com, visit the site and confirm that WordPress shows its setup page. If Caddy returns a proxy error, check the Caddy and WordPress journals and verify that both containers share the ingress network. If WordPress reports a database connection error, check MariaDB’s journal, the database host and name, and the application password secret. Try again once MariaDB accepts connections.
The source wordpress.container generates wordpress.service. A [Service] Restart=on-failure in the Quadlet can tell systemd to restart a failed service; Docker’s --restart and Compose’s restart: express a similar intention through Docker’s own lifecycle manager.
If a service does not appear after daemon-reload, inspect the generator’s errors rather than assuming systemd ignored the file:
sudo /usr/lib/systemd/system-generators/podman-system-generator --dryrun
Generated services are not persistently enabled with systemctl enable; [Install] WantedBy=multi-user.target in the Quadlet requests startup at boot. Check Podman’s installed version against the documentation before using an unfamiliar key: a key unsupported by that version may prevent the unit from being generated.
There is no single Quadlet equivalent of docker compose up or down. You manage the services and their network and volume resources explicitly. Stopping a service does not delete its data volume; deleting a volume does. Changing an image does not restore data modified by that image.
The short version
Read the Docker instructions for images, environment, storage, connectivity, and startup relationships. Give each container a Quadlet, define shared resources explicitly, and let systemd operate the generated services. Check which ports are truly exposed, whether dependencies are ready rather than merely started, and where the data lives before you change or remove anything.
For the next step, read the Quadlet overview for file locations, generation, and debugging; the .container reference for options beyond this example; the Compose application model for how Compose groups resources; and the systemd unit reference for ordering and failure dependencies.