Container Management & Lifecycle
Best practices for running, updating, and maintaining self-hosted containers on Shani OS.
---
Key Concepts
These concepts apply identically whether you're using Podman on Shani OS, Docker on Ubuntu, or Kubernetes at work. Podman is OCI-compliant and drop-in compatible with Docker — alias docker=podman works for the vast majority of workflows.
OCI (Open Container Initiative)
The open standard that defines what a container image is and how a runtime executes it. Docker, Podman, containerd, and CRI-O all implement OCI. This is why images built with docker build run unchanged under Podman and in Kubernetes.
Rootless vs. rootful containers
Rootless containers run as your user, not root — a security boundary that limits what a compromised container can do to the host. Shani OS defaults to rootless Podman. In most corporate environments you'll encounter both: rootless for application workloads, rootful for system-level tools (network namespaces, privileged devices).
Image layers and the build cache
Container images are layered — each RUN, COPY, and ADD instruction in a Dockerfile creates a new layer. Layers are cached. If your COPY instruction comes before a large apt install, every code change busts the cache and re-downloads packages. Order matters: stable instructions first, frequently changed ones last.
Container networking
By default, containers on the same compose stack share a bridge network — they reach each other by service name (e.g. db, redis). The host reaches them only on published ports. Host networking (network_mode: host) skips isolation but is occasionally needed for performance-sensitive services. Overlay networks (Swarm, Kubernetes) span multiple hosts.
Secrets management
Environment variables (environment: in compose) are readable by anyone who can run podman inspect. For production, use a secrets manager — Docker Swarm secrets, Kubernetes Secrets (ideally with external-secrets-operator + Vault), or Podman secrets (podman secret create). Never hardcode credentials in compose files that are committed to Git.
# Create a Podman secret
printf 'mysecretpassword' | podman secret create db_password -
# Reference it in a compose file
# secrets:
# db_password:
# external: true
# services:
# db:
# secrets: [db_password]
Resource limits (cgroups)
Without limits, a runaway container can OOM the host. Always set limits on untrusted or production workloads:
services:
app:
image: myapp:latest
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 128M
This maps to cgroup v2 constraints — the same mechanism Kubernetes requests/limits uses under the hood.
Container update strategies — why Watchtower/Diun instead of latest
Pinning image tags (image: nginx:1.27.2) is safer than latest — latest changes without warning and can break working deployments. But pinned tags go stale. The production pattern: pin tags in compose files, use Renovate or Diun to detect new versions, review the changelog, then update intentionally. latest is acceptable in a homelab dev environment where breakage is tolerable; never in production.
Systemd and containers — the integration model
On a non-Kubernetes host, systemd is the process supervisor. Podman generates systemd unit files (podman generate systemd) that start containers as system services, handle restart-on-failure, and integrate with journalctl for logs. Quadlet (Podman 4.4+) is the modern approach: drop a .container file in /etc/containers/systemd/, and systemd manages the container lifecycle natively. This is how production bare-metal container deployments work without Kubernetes overhead.
Image garbage collection
Pulled images accumulate on disk. Unused images (not referenced by any container) should be pruned regularly. podman image prune -a removes all unused images; podman system prune removes unused images, containers, volumes, and networks. Automate this with a systemd timer or cron. On Kubernetes, kubelet performs image GC automatically when disk usage exceeds a threshold (configurable via --image-gc-high-threshold, default 85%).
Health checks and auto-healing
A health check (HEALTHCHECK in Dockerfile, or healthcheck: in compose) lets the container runtime know if the application inside is actually functioning, not just that the process is running. If a health check fails N consecutive times, the container is marked unhealthy. restart: unless-stopped only restarts on crash — for health-check-based restarts, use autoheal (a container that watches for unhealthy status and restarts) or Kubernetes liveness probes, which restart containers that fail the check.
Container image registries — pull-through cache and private registry
A pull-through cache (Harbor, Zot) proxies Docker Hub and other registries — when you pull nginx:latest, your cache checks locally first, then fetches and caches from Docker Hub. Benefits: (1) Docker Hub rate limits (100 pulls/6h for anonymous, 200/6h authenticated) stop affecting your CI; (2) offline operation; (3) faster pulls on the LAN. A private registry stores your own built images. Harbor combines both: cache for upstream images and private registry for your own, with vulnerability scanning via Trivy built in.
Container update strategies — Watchtower vs Renovate vs manual
Three approaches: (1) Watchtower/Diun — watch for new upstream image tags and auto-pull/restart. Fast, zero-config, but opaque (you may not know what changed). Appropriate for single-user homelabs where uptime matters more than change control. (2) Renovate — creates PRs in your GitOps repo when a new image tag is published. You review the changelog, approve, and the pipeline deploys. Appropriate for production. (3) Manual — check release notes, update compose files, run podman-compose pull && podman-compose up -d. Most controlled, most work. The hybrid: Watchtower for minor patch versions, Renovate PRs for minor/major versions.
systemd and Podman integration — the production pattern
Podman generates systemd unit files (podman generate systemd) that start containers as services, handle restarts, and integrate with systemctl. Quadlet (Podman 4.4+) is the modern replacement — write a declarative .container file in ~/.config/containers/systemd/ and systemd manages the container lifecycle. Benefits over bare compose: containers start on boot, restart on failure, log to journald (available via journalctl), and participate in systemd dependencies. This is how production Podman deployments work on systems without Kubernetes.
Resource constraints — why cgroups matter for shared hosts
Without resource limits, a single misbehaving container can exhaust host CPU, memory, or disk I/O and bring down all co-located services. Podman and Docker both use cgroups for enforcement: --memory 512m kills the container if it exceeds 512 MB RAM; --cpus 0.5 limits it to half a CPU core. In compose: mem_limit: 512m, cpus: 0.5. Always set limits on containers that handle untrusted input (web servers, parsers) or that are known to leak memory over time (Java services, browsers in headless mode).
---
Systemd Integration
Containers started with --restart unless-stopped restart after crashes but not after a clean reboot if systemd doesn't know about them. The modern approach on Podman 4.4+ is Quadlet — drop a .container file and systemd picks it up automatically:
# Create the Quadlet unit directory
mkdir -p ~/.config/containers/systemd/
# Write a Quadlet unit for jellyfin (example)
cat > ~/.config/containers/systemd/jellyfin.container << 'EOF'
[Unit]
Description=Jellyfin Media Server
[Container]
Image=jellyfin/jellyfin
PublishPort=8096:8096
Volume=/home/user/jellyfin/config:/config:Z
Volume=/home/user/jellyfin/cache:/cache:Z
Environment=TZ=Asia/Kolkata
[Service]
Restart=always
[Install]
WantedBy=default.target
EOF
# Reload and start — systemd auto-generates the service from the .container file
systemctl --user daemon-reload
systemctl --user enable --now jellyfin
# Enable lingering — starts user services at boot even without an active login session
loginctl enable-linger $USER
Verify it's working
systemctl --user status jellyfin
journalctl --user -u jellyfin -f
💡 For compose-based stacks, continue using
podman-compose up -dwithrestart: unless-stopped. Quadlet is most useful for single containers you want tight systemd integration with.podman generate systemdstill works but is deprecated since Podman 4.4 and may be removed in a future release.
---
Auto-Update Containers
Podman can automatically pull new images and recreate containers on a schedule. Add the label io.containers.autoupdate=registry to any container you want updated automatically:
# ~/jellyfin/compose.yaml
services:
jellyfin:
image: jellyfin/jellyfin
labels:
io.containers.autoupdate: registry
restart: unless-stopped
cd ~/jellyfin && podman-compose up -d
Run or schedule the update
# Update all labelled containers now
podman auto-update
# Dry run to preview what would update
podman auto-update --dry-run
# Roll back a failed update
podman auto-update --rollback
Set up a weekly systemd timer using Podman's built-in service
Podman ships podman-auto-update.service — just enable the accompanying timer:
systemctl --user enable --now podman-auto-update.timer
💡 If the built-in timer is unavailable (older Podman), create one manually targeting the same service rather than running
podman auto-updatedirectly fromExecStart— this preserves rollback support.
---
OS Updates
Shani OS updates are atomic — the new image is prepared in the background, and activated on next reboot. Your @containers Btrfs subvolume is completely separate from the OS and is untouched by updates and rollbacks.
# Check for and apply updates
sudo shani-deploy
# Roll back to the previous OS generation (leaves containers untouched)
sudo shani-deploy --rollback
---
Auto-Heal Crashed Containers
Autoheal restarts any container that fails its health check — useful for services that hang without crashing outright:
# ~/autoheal/compose.yaml
services:
autoheal:
image: willfarrell/autoheal
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
environment:
AUTOHEAL_CONTAINER_LABEL: all
AUTOHEAL_INTERVAL: 30
AUTOHEAL_START_PERIOD: 300
restart: unless-stopped
Replace
1000with your actual UID (id -u). Unlike compose stacks, the autoheal container needs a literal path here —${UID}is not expanded by the container runtime.
cd ~/autoheal && podman-compose up -d
Add health checks to containers you want monitored:
# ~/jellyfin/compose.yaml
services:
jellyfin:
image: jellyfin/jellyfin
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8096/health"]
interval: 30s
retries: 3
start_period: 60s
restart: unless-stopped
cd ~/jellyfin && podman-compose up -d
---
Homepage (Service Dashboard)
Purpose: Modern, highly customisable application dashboard. Shows live status of all your services, system metrics, weather, RSS feeds, and bookmarks from a single page. Configured via YAML files and integrates directly with Docker/Podman via socket for automatic service discovery.
# ~/homepage/compose.yaml
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
ports:
- 127.0.0.1:3001:3000
volumes:
- /home/user/homepage/config:/app/config:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
restart: unless-stopped
cd ~/homepage && podman-compose up -d
Access at http://localhost:3001. Configure services, bookmarks, and widgets in /home/user/homepage/config/services.yaml.
Caddy:
dashboard.home.local { tls internal; reverse_proxy localhost:3001 }
---
Portainer CE
Purpose: Full-featured graphical dashboard for managing containers, images, volumes, networks, and stacks. Supports Podman via socket. Best for users who want a single UI for everything.
# ~/portainer/compose.yaml
services:
portainer:
image: portainer/portainer-ce:latest
ports:
- 127.0.0.1:9443:9443
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
- portainer_data:/data
restart: unless-stopped
volumes:
portainer_data:
cd ~/portainer && podman-compose up -d
Access at https://localhost:9443.
---
Dockge
Purpose: Lightweight, compose-stack-focused container manager. Stacks are stored as files and can be synced via Git. Simpler and faster than Portainer if you primarily use compose files.
# ~/dockge/compose.yaml
services:
dockge:
image: louislam/dockge:latest
ports:
- 127.0.0.1:5001:5001
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
- /home/user/stacks:/home/runner/stacks:Z
environment:
DOCKGE_STACKS_DIR: /home/runner/stacks
restart: unless-stopped
cd ~/dockge && podman-compose up -d
Access at http://localhost:5001.
---
Yacht
Purpose: Template-driven container manager with a clean dashboard and built-in app store. Good middle ground between the simplicity of Dockge and the full feature set of Portainer.
# ~/yacht/compose.yaml
services:
yacht:
image: selfhostedpro/yacht
ports:
- 127.0.0.1:8001:8000
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
- /home/user/yacht/config:/config:Z
restart: unless-stopped
cd ~/yacht && podman-compose up -d
Default login: admin@yacht.local / pass. Change immediately after first login.
---
Komodo (Modern Container Manager)
Purpose: A newer, actively developed alternative to Portainer and Dockge. Komodo manages containers, compose stacks, and deployments across multiple servers from a single dashboard. Supports Git-backed stack deployments (push to Git → auto-deploy), resource monitoring, and a clean role-based UI. Good choice for multi-server homelabs.
# ~/komodo/compose.yaml
services:
komodo:
image: ghcr.io/moghtech/komodo:latest
ports:
- 127.0.0.1:9120:9120
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
- /home/user/komodo/data:/data:Z
environment:
KOMODO_HOST: https://komodo.home.local
KOMODO_PASSKEY: changeme-run-openssl-rand-hex-32
restart: unless-stopped
cd ~/komodo && podman-compose up -d
Caddy:
komodo.home.local { tls internal; reverse_proxy localhost:9120 }
---
Diun (Image Update Notifier)
Purpose: Watches your running containers and notifies you when a new image version is available on the registry — before you auto-update. Supports ntfy, Slack, email, Telegram, and more. Useful for reviewing changelogs before pulling updates, especially for security-sensitive containers.
# ~/diun/compose.yml
services:
diun:
image: crazymax/diun:latest
volumes:
- /home/user/diun/data:/data:Z
- /home/user/diun/config.yml:/diun.yml:ro,Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
environment:
TZ: Asia/Kolkata
LOG_LEVEL: info
restart: unless-stopped
cd ~/diun && podman-compose up -d
Minimal config.yml
watch:
workers: 5
schedule: "0 */6 * * *"
firstCheckNotif: false
providers:
docker:
watchStopped: true
notif:
ntfy:
endpoint: http://host.containers.internal:8090
topic: container-updates
priority: default
tags: ["docker", "update"]
---
Cleanup & Maintenance
# Remove stopped containers, dangling images, and unused build cache
podman system prune
# Also remove all unused images (not just dangling)
podman system prune -a
# Check disk usage of container storage
podman system df
# Check Btrfs subvolume usage for the container store
sudo btrfs filesystem du -s /var/lib/containers
# Remove a specific unused image
podman rmi <image-name>
# Remove all unused volumes
podman volume prune
Set up a monthly cleanup timer
cat > ~/.config/systemd/user/podman-cleanup.service << 'EOF'
[Unit]
Description=Monthly Podman Cleanup
[Service]
Type=oneshot
ExecStart=podman system prune -f
[Install]
WantedBy=default.target
EOF
cat > ~/.config/systemd/user/podman-cleanup.timer << 'EOF'
[Unit]
Description=Monthly Podman Cleanup Timer
[Timer]
OnCalendar=monthly
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now podman-cleanup.timer
---
Useful Daily Commands
# List all running containers
podman ps
# List all containers including stopped
podman ps -a
# View live logs for a container
podman logs -f jellyfin
# Open a shell inside a running container
podman exec -it jellyfin /bin/bash
# View resource usage for all running containers
podman stats
# Inspect a container's full configuration
podman inspect jellyfin
# Check the Podman socket (needed for management UIs)
systemctl --user status podman.socket
systemctl --user enable --now podman.socket
# Copy a file from a container to the host
podman cp jellyfin:/config/config.xml ./jellyfin-config-backup.xml
# Check effective resource limits on a running container
podman inspect jellyfin | grep -A5 Memory
# Show image layer history (understand what each layer adds)
podman history jellyfin/jellyfin
---
Caddy Configuration
dashboard.home.local { tls internal; reverse_proxy localhost:3001 }
portainer.home.local { tls internal; reverse_proxy localhost:9443 }
dockge.home.local { tls internal; reverse_proxy localhost:5001 }
yacht.home.local { tls internal; reverse_proxy localhost:8001 }
komodo.home.local { tls internal; reverse_proxy localhost:9120 }
---
Troubleshooting
| Issue | Solution | |-------|----------| | Service doesn't start after reboot | Verify loginctl enable-linger $USER was run; check systemctl --user status <service> | | podman.sock: no such file | Enable the Podman socket: systemctl --user enable --now podman.socket | | Container OOM-killed | Add resource limits (deploy.resources.limits.memory) or increase host RAM; check podman inspect for current limits | | Auto-update not pulling new images | Confirm the io.containers.autoupdate=registry label is set; run podman auto-update --dry-run to verify | | Portainer / Komodo / Homepage can't connect to Podman | Use the literal socket path /run/user/$(id -u)/podman/podman.sock; verify with systemctl --user status podman.socket | | Disk full from container images | Run podman system prune -a; check podman system df for what's using space | | Container keeps restarting | Check logs with podman logs <container>; look for startup errors or missing environment variables | | Autoheal not restarting unhealthy container | Verify the health check command exits with code 1 on failure; check autoheal logs with podman logs autoheal | | Komodo servers show offline after update | KOMODO_PASSKEY must be identical between core and any periphery agents; a mismatch shows all servers offline with no clear error message | | listmonk or Postal broken after auto-update | These apps require a DB migration after image updates — run ./listmonk --upgrade --yes (listmonk) or postal initialize (Postal) before restarting; always back up the database first | | Diun not sending notifications | Check the ntfy topic and endpoint in config.yml; run podman logs diun to verify registry polling is working | | ${UID} not expanding in compose socket path | Run export UID before podman-compose, or substitute your literal UID (e.g. 1000) directly in the path |