context
The team needed an internal self-hosted time-tracking service. The
target host — mauden-services (10.1.1.117) on Rocky
Linux — was already serving other containerized workloads, which
made Podman the natural pick: rootless,
daemonless, aligned with the RHEL ecosystem.
Non-negotiable requirements: the database isolated from the corporate network, only the application port exposed, containers auto-starting after a VM reboot, and zero hardcoded credentials in the compose file.
approach
-
Two containers via Podman Compose:
sqldb(MySQL 8.3) with no port mapping to the host, andkimai(imagekimai2:apache) with0.0.0.0:8001:8001. Internal traffic flows through the private network Compose creates automatically — MySQL is only reachable as hostsqldbfrom the app. -
Credentials in
.env: a separate file (chmod 600) withDATABASE_*andADMIN_*vars, referenced in the compose via${VAR}. No cleartext passwords in the committable YAML. -
Persistent volumes:
mysqlfor/var/lib/mysql,datafor/opt/kimai/var/data,pluginsfor/opt/kimai/var/plugins. All bound with the:zsuffix — mandatory on RHEL so SELinux can relabel the content with the container's context. -
Healthcheck + dependency:
sqldbexposes amysqladmin pinghealthcheck, and Kimai declaresdepends_on.sqldb.condition: service_healthy. On cold start the DB initializes for 20–40s and Kimai waits instead of crashing on first connect. -
Explicit registries: Podman doesn't assume
Docker Hub as default, so images must be qualified as
docker.io/library/mysql:8.3anddocker.io/kimai/kimai2:apacheto avoid silent pulls from the wrong registry. -
systemd --userservice: a unit in~/.config/systemd/user/kimai.serviceof typeoneshotwithRemainAfterExit=yes, wrappingpodman-compose up -don start anddownon stop. Uses the absolute path (/home/maudadmin/.local/bin/podman-compose) because systemd doesn't expand~. -
loginctl enable-linger maudadmin: without linger,--userservices die at logout. With linger the user session stays alive even when no one is logged in, and the service starts at boot. -
firewalld — right zone:
firewall-cmd --get-active-zonesrevealed a customportainerzone catching traffic from 10.1.1.0/24 and 10.1.4.0/24. Port 8001 had to be opened on that zone, not onpublic.
outcome
- Kimai reachable stably at
http://10.1.1.117:8001from both corporate subnets. - MySQL isolated: no port mapping to the host, reachable only via the internal Compose network.
- Auto-start on reboot via user systemd + linger — validated with a full
rebootof the VM. - Zero credentials in versioned files: everything lives in
.envwith mode 600. systemctl --user status kimai.service→active (exited),status=0/SUCCESS.
constraints
Podman is daemonless. Unlike Docker there's no
always-on process restarting containers: persistence must
come from an external mechanism (systemd, @reboot
cron, etc.). That's strength, not limitation — but it forces you
to design the life-cycle explicitly.
SELinux in enforcing on RHEL. Volumes without
:z (or :Z) produce silent permission
denied inside the container, while the host sees the files
just fine. :z applies a shared relabel,
:Z a private one.
podman generate systemd is deprecated
in recent versions, and either way doesn't work with
podman-compose: it only operates on named
containers/pods, not on stacks. The clean path today is either the
new Quadlet (.container / .kube units)
or — as here — a oneshot unit wrapping
podman-compose.
problems encountered
-
Web UI unreachable from the 10.1.4.x client
despite
firewall-cmdshowing 8001/tcp open. Diagnosis:Test-NetConnectionfrom PowerShell →PingSucceeded=True,TcpTestSucceeded=False. Cause: the port was opened on thepubliczone, but traffic from 10.1.4.0/24 landed in the customportainerzone. Fix:firewall-cmd --zone=portainer --add-port=8001/tcp --permanent && firewall-cmd --reload. -
systemctl --userfailing on first start with control process exited with error.which podman-compose→~/.local/bin/podman-compose. My unit file used the tilde. Systemd doesn't expand it — fix: absolute path/home/maudadmin/.local/bin/podman-compose. -
podman generate systemd --name kimai_defaultreturned does not refer to a container or pod: the actual container names werekimai_sqldb_1andkimai_kimai_1, notkimai_default. Regardless, the command is deprecated: definitive fix was a customoneshotunit.
lessons
- On RHEL with firewalld, zones matter more than ports. Before debugging at the application layer,
firewall-cmd --get-active-zones. - The
:zSELinux suffix on Podman volumes is the kind of detail that never breaks immediately, only when you go to production. Worth setting it always. - Systemd doesn't expand
~: absolute paths everywhere, validated withsystemctl --user statusbefore declaring victory. - Without
loginctl enable-linger, user services are an illusion: they work while you're logged in, then die silently. - Healthcheck +
depends_onavoids the classic cold-start issue where the app connects to a DB still in initializing database system.