Skip to content

🐳 Install Podman Server

podman-logo Podman is an open-source container engine for developing, managing, and running containers and container images. Podman provides a Docker-compatible experience while running containers without requiring a central daemon, making it a lightweight and secure alternative for containerized applications.

Features:

  • rootless Podman container execution with automatic boot startup via systemd --user and linger
  • one dedicated Linux user per Podman service, each with isolated home/config/cache/data directories
  • strict filesystem isolation between service users, with explicit administrative override access for the debian sudoer account
  • automatic ownership/group inheritance for newly created service files and directories
  • administrative access from the debian sudoer user to all Podman service files and directories

πŸ“₯ Installation

Podman can be installed easily on debian system using apt.

# install podman and its requirements
sudo apt update
sudo apt install -y \
  podman \
  podman-compose \
  uidmap \
  passt \
  aardvark-dns \
  slirp4netns \
  fuse-overlayfs \
  systemd-container \
  openssh-server \
  skopeo \
  jq \
  dbus-x11

# install ACL
sudo apt install -y acl

# install apparmor
sudo apt install apparmor apparmor-utils
sudo systemctl enable --now apparmor

# check that podman is installed
podman --version
Install the helper image_userinfo to your shell (run once)

In rootless Podman, a container writes its data as a specific in-container UID/GID, which maps to a host subuid. For a service to read and write its bind-mounted data directories, those directories must be owned by that matching subuid, so the first thing you need to know is which user the image runs as.

The helper below reads an image's config straight from the registry and prints, in a single table:

  • the UID/GID to chown the data dirs to,
  • whether to pin User= in the quadlet,
  • and whether the image expects PUID/PGID.
if grep -qF 'image_userinfo()' ~/.bashrc 2>/dev/null; then
    echo "image_userinfo() already in ~/.bashrc β€” skipping"
else
cat >> ~/.bashrc <<'EOF'
# image_userinfo β€” for a container image, print (for rootless Podman) the uid[:gid] to
# chown its data dirs to, whether to pin User=, and whether it needs PUID/PGID.
# Reads the image config from the registry. For images whose runtime identity isn't in the
# config (named USER, a gosu/su-exec drop, or a docker-entrypoint image with a built-in
# user like redis), it pulls the image once to read /etc/passwd, then removes it again if
# it wasn't already cached.
# Usage: image_userinfo [IMAGE] [UID]   (prompts if no IMAGE; UID defaults to 1000)
# Requires: skopeo, jq  (and podman for the resolve step)
image_userinfo() {
    local img="${1:-}" want="${2:-1000}"
    if [ -z "$img" ]; then
        read -r -p "Docker image path: " img
        [ -z "$img" ] && { echo "error: no image provided" >&2; return 1; }
    fi
    local t; for t in skopeo jq; do
        command -v "$t" >/dev/null 2>&1 || { echo "error: '$t' not found (sudo apt install $t)" >&2; return 1; }
    done
    local cfg; cfg="$(skopeo inspect --config "docker://${img}" 2>/dev/null)" \
        || { echo "error: cannot read config for '$img'" >&2; return 1; }

    local user entry cmd ep0 cmd0 imgenv vendor src maint base cands
    user="$(jq -r '.config.User // ""'                                        <<<"$cfg")"
    entry="$(jq -r '(.config.Entrypoint // []) | join(" ")'                   <<<"$cfg")"
    cmd="$(jq -r '(.config.Cmd // []) | join(" ")'                            <<<"$cfg")"
    ep0="$(jq -r '(.config.Entrypoint // [])[0] // ""'                        <<<"$cfg")"
    cmd0="$(jq -r '(.config.Cmd // [])[0] // ""'                              <<<"$cfg")"; cmd0="${cmd0##*/}"
    imgenv="$(jq -r '(.config.Env // []) | join(" ")'                         <<<"$cfg")"
    vendor="$(jq -r '.config.Labels["org.opencontainers.image.vendor"] // ""' <<<"$cfg")"
    src="$(jq -r '.config.Labels["org.opencontainers.image.source"]    // ""' <<<"$cfg")"
    maint="$(jq -r '.config.Labels.maintainer // ""'                          <<<"$cfg")"
    base="${img##*/}"; base="${base%%:*}"
    cands="$base ${base%%-*} $cmd0 ${cmd0%%-*}"        # candidate built-in usernames

    local is_s6=false is_lsio=false is_gosu=false is_wrapper=false
    case " $entry " in *" /init "*) is_s6=true ;; esac
    case " $cmd "   in *" /init "*) is_s6=true ;; esac
    case "$(printf '%s %s %s' "$vendor" "$src" "$maint" | tr 'A-Z' 'a-z')" in
        *linuxserver*) is_lsio=true ;;
    esac
    case " $imgenv " in *" GOSU_VERSION="*) is_gosu=true ;; esac
    case "$ep0"      in *docker-entrypoint*) is_wrapper=true ;; esac

    # Resolve a real identity from the image's /etc/passwd (pulls if needed, then removes
    # the image again only if it wasn't already cached). Looks for: a given username, else
    # the entrypoint's gosu/su-exec target, else a built-in user matching the image name.
    __iu_resolve() {   # <img> <user|''> <entrypoint|''> <candidates>  -> "uid[:gid]" ('' if unknown)
        command -v podman >/dev/null 2>&1 || return 0
        local i="$1" u="$2" ep="$3" cl="$4" present=false line uid gid
        podman image exists "$i" 2>/dev/null && present=true
        $present || echo "  …resolving identity from $i (one-time pull)…" >&2
        line="$(podman run --rm --entrypoint sh "$i" -c '
            u="$1"; ep="$2"; cl="$3"
            if [ -z "$u" ]; then
                [ -n "$ep" ] && [ ! -f "$ep" ] && ep="$(command -v "$ep" 2>/dev/null)"
                for f in "$ep" /usr/local/bin/docker-entrypoint.sh /docker-entrypoint.sh /entrypoint.sh; do
                    [ -f "$f" ] || continue
                    u="$(grep -hoE "(gosu|su-exec) [A-Za-z0-9_-]+" "$f" 2>/dev/null | awk "{print \$2}" | head -n1)"
                    [ -n "$u" ] && break
                done
            fi
            if [ -z "$u" ]; then
                for c in $cl; do grep -q "^${c}:" /etc/passwd 2>/dev/null && { u="$c"; break; }; done
            fi
            [ -n "$u" ] || exit 0
            grep "^${u}:" /etc/passwd 2>/dev/null || getent passwd "$u" 2>/dev/null
        ' _ "$u" "$ep" "$cl" 2>/dev/null)"
        $present || podman rmi "$i" >/dev/null 2>&1
        uid="$(printf '%s' "$line" | cut -d: -f3 | tr -dc '0-9')"
        gid="$(printf '%s' "$line" | cut -d: -f4 | tr -dc '0-9')"
        [ -z "$uid" ] && return 0
        if [ -n "$gid" ] && [ "$gid" != "$uid" ]; then printf '%s:%s' "$uid" "$gid"; else printf '%s' "$uid"; fi
    }

    local kind quser puid chownto res ru rg
    if $is_s6 || $is_lsio; then
        kind="LinuxServer/s6"; quser="don't set"; puid="PUID/PGID=${want}"; chownto="${want}"
    elif [ -n "$user" ]; then
        kind="declares USER ($user)"; quser="not needed"; puid="no"
        if [[ "$user" =~ ^[0-9]+(:[0-9]+)?$ ]]; then
            chownto="${user}"
        else
            res="$(__iu_resolve "$img" "${user%%:*}" "$ep0" "$cands")"
            chownto="${res:-${user} (verify uid)}"
        fi
    elif $is_gosu || $is_wrapper; then
        res="$(__iu_resolve "$img" "" "$ep0" "$cands")"; puid="no"
        if [ -n "$res" ]; then
            ru="${res%%:*}"; rg="${res#*:}"; [ "$rg" = "$res" ] && rg="$ru"
            $is_gosu && kind="root -> gosu-drop" || kind="root + built-in user"
            quser="User=${ru}:${rg}"; chownto="${res}"
        else
            kind="no USER -> root"; quser="User=${want}:${want}"; chownto="${want}"
        fi
    else
        kind="no USER -> root"; quser="User=${want}:${want}"; puid="no"; chownto="${want}"
    fi

    local -a H=("Image" "Type" "Quadlet User=" "PUID/PGID" "data_dirs to")
    local -a V=("$img" "$kind" "$quser" "$puid" "$chownto")
    local -a W=(); local i len
    for i in 0 1 2 3 4; do W[i]=${#H[i]}; len=${#V[i]}; [ "$len" -gt "${W[i]}" ] && W[i]=$len; done
    __iu_seg() { local n=$(( $1 + 2 )) s=''; while [ "$n" -gt 0 ]; do s+='─'; n=$((n-1)); done; printf '%s' "$s"; }
    printf 'β”Œ'; for i in 0 1 2 3 4; do printf '%s' "$(__iu_seg "${W[i]}")"; [ "$i" -lt 4 ] && printf '┬'; done; printf '┐\n'
    printf 'β”‚'; for i in 0 1 2 3 4; do printf ' %-*s β”‚' "${W[i]}" "${H[i]}"; done; printf '\n'
    printf 'β”œ'; for i in 0 1 2 3 4; do printf '%s' "$(__iu_seg "${W[i]}")"; [ "$i" -lt 4 ] && printf 'β”Ό'; done; printf '─\n'
    printf 'β”‚'; for i in 0 1 2 3 4; do printf ' %-*s β”‚' "${W[i]}" "${V[i]}"; done; printf '\n'
    printf 'β””'; for i in 0 1 2 3 4; do printf '%s' "$(__iu_seg "${W[i]}")"; [ "$i" -lt 4 ] && printf 'β”΄'; done; printf 'β”˜\n'
    unset -f __iu_seg __iu_resolve
}
EOF
echo "image_userinfo() added to ~/.bashrc"
fi
source ~/.bashrc

πŸ” Rootless Services

Each Podman service runs as its own dedicated Linux user to provide strong isolation between containers, configuration, runtime state, and persistent data.

Example:

  • n8n β†’ user n8n_svc, group n8n_admins
  • immich β†’ user immich_svc, group immich_admins

The debian administrative user belongs to all *_admins groups, allowing centralized management of every service while keeping services isolated from each other. Service users belong only to their own primary group and do not share access with other services.

Each service uses two separate storage locations:

Path Purpose
/media/ssd/podman/<service>/ persistent application data, configuration, environment files, and Quadlet definitions
/media/ssd/podman-users/<service>/ rootless Podman user home containing container runtime state, images, volumes, cache, and user configuration

Example layout:

/media/ssd/podman/service/
β”œβ”€β”€ Containerfile
β”œβ”€β”€ postgres.env
β”œβ”€β”€ service.env
β”œβ”€β”€ quadlet/
β”‚   β”œβ”€β”€ service.network
β”‚   β”œβ”€β”€ postgres.container
β”‚   └── service.container
└── data/
    β”œβ”€β”€ service/
    └── postgres-db/

Rootless Podman requires a valid home directory for each service user because Podman stores per-user runtime data under:

/media/ssd/podman-users/<service>/
β”œβ”€β”€ .config/
β”œβ”€β”€ .cache/
└── .local/share/containers/

Container runtime storage is isolated per service user:

~/.local/share/containers/
└── storage/
    β”œβ”€β”€ overlay/              # container filesystem layers
    β”œβ”€β”€ overlay-images/       # container image metadata
    β”œβ”€β”€ overlay-containers/   # container metadata
    └── volumes/              # named Podman volumes

Each service user therefore has its own isolated:

  • container images
  • containers
  • named volumes
  • network namespace
  • Podman configuration
  • runtime state

βš™οΈ Configuration

πŸ“ Base Directories

Prepare the base directories used to store service data and per-user Podman runtime files. The permissions below keep the directories manageable by the debian administrative user while limiting access for other users.

Create podman-users home directory
# create podman-users home directory
# u=rwX β†’ owner gets:
#   - rw for files
#   - rwx for directories
#   - preserves execute bit only for already executable files
# go=x  β†’ group and others get traverse permission on directories only
sudo mkdir -p /media/ssd/podman-users
sudo chown debian:debian /media/ssd/podman-users
sudo chmod u=rwX,go=x /media/ssd/podman-users
Create podman services directory
# create podman services directory
# u=rwX β†’ owner gets:
#   - rw for files
#   - rwx for directories
#   - preserves execute bit only for already executable files
# go=x  β†’ group and others get traverse permission on directories only
sudo mkdir -p /media/ssd/podman
sudo chown debian:debian /media/ssd/podman
sudo chmod u=rwX,go=x /media/ssd/podman
Harden debian user home
# harden debian user home
# u=rwX β†’ owner gets:
#   - rw for files (600)
#   - rwx for directories (700)
#   - preserves execute bit only for already executable files
# go=   β†’ group and others get no permissions
sudo chmod u=rwX,go= /home/debian
Harden /media/ssd top-level directory
# harden the ssd top-level directory
# u=rwX β†’ owner keeps full access
# go=x  β†’ group and others get traverse permission only: every account can
#         still pass through to reach its own subdirectory, but none can
#         list what else lives on the SSD
sudo chmod u=rwX,go=x /media/ssd
Audit /media/ssd top-level permissions

Run this after adding any new directory on the SSD:

# one line per entry: mode, owner:group, path
sudo find /media/ssd -maxdepth 1 -mindepth 1 -exec stat -c '%A %U:%G %n' {} +

Rule of thumb: the last permission triplet ("others") of every entry should show at most --x. An account that needs more on a directory (e.g. www-data on an html directory, the mkdocs container on its bare repo) gets a named ACL entry (setfacl -m u:<user>:...), never world bits β€” a + after the mode means such entries exist, inspect them with getfacl.


πŸ‘₯ User and Group

This step creates a dedicated Linux user for the Podman service and a matching administration group for controlled access. The service runs under its own isolated account, while the debian user is added to the admin group to manage the service files when needed.

Define Service Variables

# define the service name
SERVICE_NAME=XXX

Replace XXX by your service name

Initialize Service Environment
# built-in bash safety and SERVICE_NAME validation
set -euo pipefail; [ -n "${SERVICE_NAME:-}" ] || { echo "SERVICE_NAME is empty"; exit 1; }

# automatically generate variables
SERVICE_HOME="/media/ssd/podman-users/${SERVICE_NAME}"
SERVICE_USER="${SERVICE_NAME}_svc"
SERVICE_ADMIN="${SERVICE_NAME}_admins"
Create Service User and Access Control
# create Linux user for this service
#   --system      β†’ create a dedicated system account (non-human service user)
#   --create-home β†’ automatically create the user's home directory
#   --home-dir    β†’ define the service user's home directory location
#   --shell       β†’ allow local interactive shell access for service administration
# name of the Linux service user to create
sudo useradd \
  --system \
  --create-home \
  --home-dir "${SERVICE_HOME}" \
  --shell /bin/bash \
  "${SERVICE_USER}"

# restrict SSH access for service users
getent group no-ssh >/dev/null || sudo groupadd no-ssh            # create no-ssh group only if missing
id -nG "${SERVICE_USER}" | grep -qw no-ssh ||                     # add service user to no-ssh group only if not already a member
  sudo usermod -aG no-ssh "${SERVICE_USER}"
sudo install -d -m 755 /etc/ssh/sshd_config.d                     # ensure the drop-in directory exists
SSHD_CONFIG="/etc/ssh/sshd_config.d/99-deny-service-users.conf"
sudo tee "${SSHD_CONFIG}" >/dev/null <<EOF                        # enforce SSH deny rule for members of no-ssh
DenyGroups no-ssh
EOF
sudo sshd -t                                                      # test SSH configuration
sudo systemctl reload ssh                                         # reload SSH daemon configuration

# create Linux group for this service and allow debian user to access it
sudo groupadd "${SERVICE_ADMIN}"
sudo usermod -aG "${SERVICE_ADMIN}" debian
Making XDG_RUNTIME_DIR available automatically per user

XDG_RUNTIME_DIR points to the per-user runtime directory (/run/user/) where systemd, D-Bus, and rootless tools like Podman keep their session sockets and state. Setting it in each service user's login profile ensures these tools can find that directory when you switch users with sudo -iu, since that command doesn't always open a full login session that would set it automatically.

# make it persistent: set it automatically on every future login
sudo tee -a "$(getent passwd "${SERVICE_USER}" | cut -d: -f6)/.profile" >/dev/null <<'EOF'

# Provide XDG_RUNTIME_DIR for rootless/user services when missing
if [ -z "${XDG_RUNTIME_DIR}" ]; then
    export XDG_RUNTIME_DIR="/run/user/$(id -u)"
    export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
fi
EOF

Notify: re-login required for group changes

# logout and back for group membership to apply
echo "Log out and back in later for ${SERVICE_ADMIN} group membership to apply in interactive shells."
exit

πŸ” Persistent Services

Rootless Podman containers run inside the service user's systemd --user session. Without lingering enabled, user services may only start after the user logs in and may stop after logout.

Define Service Variables

# define the service name
SERVICE_NAME=XXX

Replace XXX by your service name

Initialize Service Environment
# built-in bash safety and SERVICE_NAME validation
set -euo pipefail; [ -n "${SERVICE_NAME:-}" ] || { echo "SERVICE_NAME is empty"; exit 1; }

# automatically generate variables
SERVICE_HOME="/media/ssd/podman-users/${SERVICE_NAME}"
SERVICE_DIR="/media/ssd/podman/${SERVICE_NAME}"
SERVICE_USER="${SERVICE_NAME}_svc"
SERVICE_ADMIN="${SERVICE_NAME}_admins"
Enable Persistent User Services with Lingering
# retrieve the service user's numeric UID
SERVICE_UID="$(id -u "${SERVICE_USER}")"

# allow the user's systemd --user manager to run without interactive login
sudo loginctl enable-linger "${SERVICE_USER}"

# immediately start the user's systemd instance
sudo systemctl start "user@${SERVICE_UID}.service"

πŸ” Detecting a container's runtime UID/GID

Detecting a container's runtime UID/GID

Once installed, run image_userinfo to see how an image runs and what its data directory permissions should be.

image_userinfo                                        # prompts for the image
image_userinfo docker.io/library/postgres:16-alpine   # with docker image argument

It reads the image config from the registry and prints a single table:

debian@odroid:~$ image_userinfo docker.io/library/postgres:16-alpine
  …resolving uid from docker.io/library/postgres:16-alpine (one-time pull)…
+--------------------------------------+-------------------+---------------+-----------+--------------+
| Image                                | Type              | Quadlet User= | PUID/PGID | data_dirs to |
+--------------------------------------+-------------------+---------------+-----------+--------------+
| docker.io/library/postgres:16-alpine | root -> gosu-drop | User=70:70    | no        | 70           |
+--------------------------------------+-------------------+---------------+-----------+--------------+

Reading the table

Column Meaning
Image The image reference that was inspected.
Type How the image sets its user β€” declares USER (name) (runs as that user), no USER -> root (runs as root), or LinuxServer/s6 (drops to PUID/PGID).
Quadlet User= What to put in the .container file: a User=uid:gid line to pin, not needed (image already sets it), or don't set (s6 images must stay root).
PUID/PGID Whether to set PUID/PGID in the service's env file β€” only LinuxServer/s6 images use them.
data_dirs to The uid/gid that must own the bind-mounted data directory β€” i.e. the value to use in your data_dirs list.

πŸŽ›οΈ Podman Control

podmanctl is a single-file Python tool that gives the debian admin account a console dashboard and lifecycle control over every rootless Podman Quadlet service on the server. Each service runs in its own isolated userspace where a plain podman ps from debian sees nothing; podmanctl bridges that gap with a single command.

Features:

  • list the table of all containers from all service users, with health status and systemd unit state
  • start / stop / restart / update any service stack from the debian account, in dependency order β€” start/stop/restart/git-pull act on all services at once when no service is given
  • update a service end to end: stop β†’ git pull --rebase its repository β†’ rebuild/re-pull images β†’ start
  • check against the registries (skopeo) to see which images have a newer version available β€” local builds are checked through the base images of their Dockerfile FROM lines
  • git pull (git pull --rebase) one service data directory under /media/ssd/podman/, or every git repository at once when no service is given
  • fully dynamic discovery: there is no configuration file to maintain; everything is derived live from the conventions:
    • services: every subdirectory of /media/ssd/podman-users/ that has a matching <name>_svc Linux user
    • units, containers, images: parsed from the *.container and *.build Quadlet files in each service's ~/.config/containers/systemd/, which the debian account can read sudo-less thanks to the <name>_admins group ACLs
    • start/stop order: a topological sort of the After=/Requires= relations between the service's own units
    • live state: podman ps and systemctl --user executed inside each service user's session (sudo -u <user> env HOME=<home> XDG_RUNTIME_DIR=/run/user/<uid> ...)

Installing a new service the usual way (new <name>_svc user + Quadlets) makes it appear in the dashboard automatically.

βš™οΈ Install the script

Install podmanctl to /usr/local/bin
# download the script published by this documentation site
sudo curl -fsSL https://docs.fum-server.fr/files/podmanctl.py -o /usr/local/bin/podmanctl

# make it executable for everyone, writable by root only
sudo chmod 0755 /usr/local/bin/podmanctl

# check the installation
podmanctl --help

The script is plain Python 3 standard library β€” no pip package is needed.


πŸš€ Usage

πŸ“Š Dashboard

List services with podmanctl --list
podmanctl
SERVICE      USER             CONTAINER          IMAGE                                    STATUS                   UNIT
calibre      calibre_svc      calibre-server     localhost/calibre:7.21.0                 Up 2 days (healthy)      active
gokapi       gokapi_svc       gokapi-server      docker.io/f0rc3/gokapi:latest            Up 2 days (healthy)      active
immich       immich_svc       immich-postgres    ghcr.io/immich-app/postgres:14-vec…      Up 2 days (healthy)      active
immich       immich_svc       immich-redis       docker.io/redis:6.2-alpine               Up 2 days (healthy)      active
immich       immich_svc       immich-server      localhost/immich:v2.7.5                  Up 2 days (healthy)      active
planka       planka_svc       planka-postgres    docker.io/library/postgres:16-alpine     Up 5 hours (healthy)     active
planka       planka_svc       planka-server      ghcr.io/plankanban/planka:2.0.0-rc.4     Exited (1) 3 min ago     failed
...
Check for available updates with podmanctl --list --check
podmanctl --list --check
SERVICE   USER        CONTAINER        IMAGE                                  STATUS                UNIT    UPDATE
gokapi    gokapi_svc  gokapi-server    docker.io/f0rc3/gokapi:latest          Up 2 days (healthy)   active  update available
immich    immich_svc  immich-postgres  ghcr.io/immich-app/postgres:14-vec…    Up 2 days (healthy)   active  up-to-date
immich    immich_svc  immich-redis     docker.io/redis:6.2-alpine             Up 2 days (healthy)   active  up-to-date
immich    immich_svc  immich-server    localhost/immich:v2.7.5                Up 2 days (healthy)   active  up-to-date
...

Images produced by a .build Quadlet are checked through their Dockerfile FROM base images (resolved from the Quadlet's build args): the bases were pulled into the service user's storage at build time, so if a base moved on the registry the build is reported as update available. Local sources (COPY'd code, git content) are not part of the comparison.

πŸ” Start / Stop / Restart

Start/Stop/Restart service with podmanctl --<action> <service>
podmanctl --start immich      # daemon-reload, then start (databases first)
podmanctl --stop immich       # stop the stack (server first, databases last)
podmanctl --restart immich    # stop + start
Start/Stop/Restart ALL services with podmanctl --<action>

Omit the service name to apply the action to every detected service, with a summary table at the end:

podmanctl --restart
Stopping calibre ...
  $ (calibre_svc) systemctl --user stop calibre-server.service
Starting calibre ...
  $ (calibre_svc) systemctl --user daemon-reload
  $ (calibre_svc) systemctl --user start calibre-server.service
...
SERVICE      RESULT
calibre      βœ…
gokapi       βœ…
immich       βœ…
planka       ❌
...

βœ… done β€” ❌ error (the failing unit is visible in the live output above the table).

⬆️ Update a service

Update a service with podmanctl --update <service>

--update runs, in order: stop the stack β†’ git pull --rebase the service repository β†’ rebuild local images / re-pull registry images β†’ start the stack. The service stays down for the whole rebuild, and a failed git pull aborts the update and leaves the service stopped.

# 1. bump the version in the service env file
sudo nano /media/ssd/podman/immich/immich.env     # IMMICH_VERSION=v2.8.0

# 2. stop, git pull the source, rebuild the images and restart
podmanctl --update immich
# nothing to edit: stop, pull the new :latest and restart
podmanctl --update n8n

A service whose data directory is not a git repository simply skips the git pull step (βž–) and updates its images as usual.

⬇️ Pull git repositories

Pull service git repositories with podmanctl --git-pull [service]
podmanctl --git-pull            # every service repository (all at once)
  $ git -C /media/ssd/podman/calibre pull --rebase
  $ git -C /media/ssd/podman/immich pull --rebase
  $ git -C /media/ssd/podman/planka pull --rebase
...
SERVICE      RESULT
calibre      βœ…
gokapi       βž–
immich       βœ…
planka       ❌
...

podmanctl --git-pull immich     # a single service repository
  $ git -C /media/ssd/podman/immich pull --rebase
SERVICE      RESULT
immich       βœ…

Omit the service name to pull every repository; pass a <service> to pull only that one. Repositories are detected as /media/ssd/podman/<name>/.git and pulled with git pull --rebase as the debian account β€” no sudo involved. βœ… new commits pulled β€” βž– nothing done (already up to date, or not a git repository) β€” ❌ pull failed (the git error is printed below the table).

πŸ“œ Logs

Follow a service's journal with podmanctl --logs <service>

podmanctl --logs immich       # sudo journalctl _UID=<immich_svc uid> -n 50 -f

πŸ—‘οΈ Removing a Service

Removing a service reverses its setup:

  • stops the running stack
  • deletes the dedicated Linux user
  • delete its admin group and rootless ID mappings
  • erases both the per-user Podman home and the service data directory

Define Service Variables

# define the service name
SERVICE_NAME=XXX

Replace XXX by your service name

Initialize Service Environment
# built-in bash safety and SERVICE_NAME validation
set -euo pipefail; [ -n "${SERVICE_NAME:-}" ] || { echo "SERVICE_NAME is empty"; exit 1; }

# automatically generate variables
SERVICE_HOME="/media/ssd/podman-users/${SERVICE_NAME}"
SERVICE_DIR="/media/ssd/podman/${SERVICE_NAME}"
SERVICE_USER="${SERVICE_NAME}_svc"
SERVICE_ADMIN="${SERVICE_NAME}_admins"
Removing a service entirely
# show the absolute path and size of each directory to be deleted
echo "The following directories will be permanently deleted for '${SERVICE_NAME}':"
for dir in "${SERVICE_HOME}" "${SERVICE_DIR}"; do
    if [ -d "${dir}" ]; then
        printf '  %s  (%s)\n' "${dir}" "$(sudo du -sh "${dir}" 2>/dev/null | cut -f1)"
    else
        printf '  %s  (not found)\n' "${dir}"
    fi
done

# ask for confirmation, abort otherwise (read from the terminal, not the pasted input)
read -r -p "Type 'yes' to confirm permanent removal: " CONFIRM </dev/tty
[ "${CONFIRM}" = "yes" ] || { echo "Aborted, nothing was removed."; exit 1; }

# stop the service stack from the debian admin account
podmanctl --stop "${SERVICE_NAME}"

# retrieve the service user's numeric UID (needed to stop its systemd --user instance)
SERVICE_UID="$(id -u "${SERVICE_USER}")"

# disable lingering so the user's systemd --user manager no longer starts at boot
sudo loginctl disable-linger "${SERVICE_USER}"

# stop the service user's systemd --user instance (kills any container still running)
sudo systemctl stop "user@${SERVICE_UID}.service"

# remove the service user (its home directory is deleted explicitly below)
sudo userdel "${SERVICE_USER}"

# remove the per-user Podman home and the service data directory
sudo rm -rf "${SERVICE_HOME}" "${SERVICE_DIR}"

# remove the admin group (debian's membership is dropped with it)
sudo groupdel "${SERVICE_ADMIN}"

# remove the rootless subuid/subgid mappings (in case userdel left them behind)
sudo sed -i "/^${SERVICE_USER}:/d" /etc/subuid /etc/subgid

The shared no-ssh group and the /etc/ssh/sshd_config.d/99-deny-service-users.conf deny rule are left untouched, they are used by every service user.