all projects
// featured · production

Kimai self-hosted · Podman Compose on RHEL

Kimai (time-tracking) deployed on Rocky Linux with Podman Compose: two containers — the Kimai app and the MySQL 8.3 database — on a private internal network, only port 8001 exposed outside. Reboot-safe persistence via a systemd --user service with linger, firewall opened on the correct custom zone.

Statuscompleted
Year2026
Roleproduction · deploy
EnvironmentRocky Linux · Podman · MySQL 8.3
RHEL Rocky Linux Podman Podman Compose MySQL 8.3 systemd firewalld SELinux

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, and kimai (image kimai2:apache) with 0.0.0.0:8001:8001. Internal traffic flows through the private network Compose creates automatically — MySQL is only reachable as host sqldb from the app.
  • Credentials in .env: a separate file (chmod 600) with DATABASE_* and ADMIN_* vars, referenced in the compose via ${VAR}. No cleartext passwords in the committable YAML.
  • Persistent volumes: mysql for /var/lib/mysql, data for /opt/kimai/var/data, plugins for /opt/kimai/var/plugins. All bound with the :z suffix — mandatory on RHEL so SELinux can relabel the content with the container's context.
  • Healthcheck + dependency: sqldb exposes a mysqladmin ping healthcheck, and Kimai declares depends_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.3 and docker.io/kimai/kimai2:apache to avoid silent pulls from the wrong registry.
  • systemd --user service: a unit in ~/.config/systemd/user/kimai.service of type oneshot with RemainAfterExit=yes, wrapping podman-compose up -d on start and down on stop. Uses the absolute path (/home/maudadmin/.local/bin/podman-compose) because systemd doesn't expand ~.
  • loginctl enable-linger maudadmin: without linger, --user services 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-zones revealed a custom portainer zone catching traffic from 10.1.1.0/24 and 10.1.4.0/24. Port 8001 had to be opened on that zone, not on public.

outcome

  • Kimai reachable stably at http://10.1.1.117:8001 from 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 reboot of the VM.
  • Zero credentials in versioned files: everything lives in .env with mode 600.
  • systemctl --user status kimai.serviceactive (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-cmd showing 8001/tcp open. Diagnosis: Test-NetConnection from PowerShell → PingSucceeded=True, TcpTestSucceeded=False. Cause: the port was opened on the public zone, but traffic from 10.1.4.0/24 landed in the custom portainer zone. Fix: firewall-cmd --zone=portainer --add-port=8001/tcp --permanent && firewall-cmd --reload.
  • systemctl --user failing 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_default returned does not refer to a container or pod: the actual container names were kimai_sqldb_1 and kimai_kimai_1, not kimai_default. Regardless, the command is deprecated: definitive fix was a custom oneshot unit.

lessons

  • On RHEL with firewalld, zones matter more than ports. Before debugging at the application layer, firewall-cmd --get-active-zones.
  • The :z SELinux 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 with systemctl --user status before declaring victory.
  • Without loginctl enable-linger, user services are an illusion: they work while you're logged in, then die silently.
  • Healthcheck + depends_on avoids the classic cold-start issue where the app connects to a DB still in initializing database system.
matrix-mode · ON