Merge portainer into podman: engine plus GUI in one module

Podman is the container foundation, Portainer CE is the web interface onto it,
running as a container on the very engine it manages. This is what the separate
portainer module already did, so it is retired rather than duplicated.

Two sockets on purpose: the rootful one is the Docker-compatible API Portainer
drives (it does not support rootless), the rootless user socket is for people
who ssh in and run containers by hand — which was the original podman module's
point and is worth keeping.

An explicit proxyDomain lets the module publish at the environment's own domain
instead of <module>.<environment-domain>, making it that environment's gateway.

DESIGN.md keeps the Cockpit identity analysis: it is the reason for the change,
and anyone proposing 'just put Cockpit behind forward-auth' should read it.
This commit is contained in:
Lars Rossen 2026-08-25 09:30:11 +02:00
parent ffc9e12526
commit 04b4437c95
15 changed files with 389 additions and 793 deletions

View file

@ -32,16 +32,16 @@ URL, and the clone is made over HTTPS.
## Modules
Three parallel takes on the same job — run containers on a lab host, let registered people
manage them, reach the other hosts in the zone. They exist side by side on purpose.
Two takes on the same job — run containers on a lab host, let registered people manage them,
reach the other hosts in the zone.
| Module | What it is | Status |
| --- | --- | --- |
| [portainer](src/containers/portainer) | Portainer CE on rootful Podman; OIDC login, agents on other hosts | incomplete — **the one the session uses** |
| [podman](src/containers/podman) | Podman engine + Portainer CE console; OIDC login, agents on other hosts | incomplete — **the one the session uses** |
| [komodo](src/containers/komodo) | GPL Core + Periphery alternative; scaffold, scripts specified but not written | scaffold |
| [podman](src/containers/podman) | Plain Podman host with the Cockpit console; single host, local login | incomplete |
Why three: Cockpit turned out to fit neither requirement — it is not an OIDC client and
cannot be made one, and its multi-host switcher is deprecated and disabled by default because
it "cannot be secure". The reasoning is written up in
`podman` absorbed the former standalone `portainer` module: Podman is the container
foundation, Portainer is the GUI onto it, one VM. Cockpit was dropped because it fits neither
requirement — it is not an OIDC client and cannot be made one, and its multi-host switcher is
deprecated and disabled by default because it "cannot be secure". The reasoning is in
[podman/DESIGN.md](src/containers/podman/DESIGN.md#identity-integration--the-analysis).

View file

@ -75,11 +75,18 @@ Option 1 or 2 plus a custom Cockpit auth command that trusts `X-Authentik-Userna
full bypass if the port is reachable any other way, and this module currently *advertises*
direct `https://<vm-ip>:9090` access as a feature.
### Recommendation
### What was actually done (2026-08-25)
Take option 1 now, open an upstream issue for option 2, and treat option 3 as out of scope.
Note that the direct `:9090` URL stays open under option 1 — the gate is on the friendly
name only, which is honest but worth saying out loud in `README.md`.
**None of the three.** All of them are workarounds for a console that cannot take an OIDC
login, and the requirement had grown: registered people managing containers *across the hosts
in the zone*, which Cockpit's deprecated host switcher cannot do securely either.
So Cockpit was dropped and **Portainer CE** became this module's GUI, with Podman still the
engine underneath it. Portainer *is* an OIDC client, so `identity:identity` — the native TAPPaaS
OIDC path — works exactly as designed, and multi-host is an agent per host rather than a
browser loading remote JavaScript. The analysis above is kept because it is the reason for
the change, and because anyone proposing "just put Cockpit behind forward-auth" should read it
first.
### One thing to verify on a live system first

View file

@ -1,110 +1,95 @@
# Podman container host — Installation
# Podman + Portainer — Installation
Primary audience: TAPPaaS admin. Steps the scripts cannot automate.
## Prerequisites
- `tappaas@tappaas-cicd` with the usual cluster SSH/sudo access.
- The dependencies are pulled in automatically (`cluster:vm`, `templates:debian`,
`backup:vm`, `network:proxy`).
- For the friendly HTTPS name to present a valid certificate, the TAPPaaS wildcard must be in
OPNsense Trust (run `acme-setup.sh` once — see the platform INSTALL). Until then the internal
endpoint still works, with a cert warning (Cockpit also ships a self-signed cert).
- Dependencies resolve automatically (`cluster:vm`, `templates:debian`, `backup:vm`,
`network:proxy`, `identity:identity`).
- The identity foundation must be up — the login depends on it.
## Install
```bash
cd /home/tappaas/Community/src/larsrossen/containers/podman
install-module.sh podman
# Published at <vmname>.<environment-domain>:
module-manager module add podman --environment lab1
# …or as the environment's gateway, at its own domain:
module-manager module add podman --environment lab1 --proxyDomain lab1.makerfloss.eu
```
This automatically:
- creates a Debian 13 VM (vmid 812, 2 vCPU / 2 GB / 20 GB) and OS-preps it;
- installs `podman`, `podman-compose`, `cockpit` and `cockpit-podman` from apt;
- enables the rootless podman socket (with linger) and the Cockpit console on `:9090`;
- publishes the internal reverse-proxy name `podman.<domain>` (console reachable from the
`mgmt` zone only, no internet).
### Placement / zone
- creates a Debian 13 VM (vmid 812, 2 vCPU / 2 GB / 32 GB) and OS-preps it;
- installs `podman`, `podman-compose`, `slirp4netns`, `uidmap`;
- enables the **rootful** `podman.socket` (Portainer's API) and `podman-restart.service`,
plus the **rootless** user socket with linger, for people who ssh in;
- runs `portainer-ce:lts` against the rootful socket with a named volume;
- creates a break-glass local admin at `/etc/secrets/podman-admin`;
- reads the OIDC credentials `identity:identity` left in `/etc/secrets/podman.env` and
switches Portainer to OAuth login;
- publishes the console through the reverse proxy.
The module JSON sets **no `zone0`**, so the VM lands in the **default environment's zone**
(ADR-007 P5: with `zone0` unset, install resolves the zone from
`config/environments/<env>.json → network.zone`). To place it in a specific environment — and
therefore its associated zone — pass `--environment` at install time:
## Post-install (manual)
### 1. Who can log in
Authentik binds the application to the allowed groups at install. To let the hackerspace in:
```bash
install-module.sh podman --environment <env> # VM joins <env>'s network.zone
people-manager user modify <user> --add-groups devops
people-manager reconcile --apply
```
An explicit `zone0` in the JSON would override this, so it is intentionally omitted.
### 2. Other hosts in the zone
## Post-install (manual — required for the web login)
The Cockpit console authenticates against **Linux (PAM) accounts on the VM**. The cloud-init
`tappaas` user has SSH-key auth and **no password**, so it cannot log in to the web console
until you give it one (or create a dedicated admin user).
**Option A — give the `tappaas` user a password:**
On each additional VM or physical machine:
```bash
ssh tappaas@<vm-ip> 'sudo passwd tappaas'
podman run -d --name portainer_agent --restart=always \
-p 9001:9001 \
-v /run/podman/podman.sock:/var/run/docker.sock \
-v /var/lib/containers/storage/volumes:/var/lib/docker/volumes \
docker.io/portainer/agent:lts
```
**Option B — create a dedicated admin user (recommended for shared access):**
Then **Environments → Add environment → Docker Standalone → Agent**, address `<host>:9001`.
Same-zone traffic is allowed by default; another zone needs a pinhole into `:9001`.
### 3. Break-glass access
```bash
ssh tappaas@<vm-ip>
sudo useradd -m -s /bin/bash -G sudo podadmin && sudo passwd podadmin
sudo loginctl enable-linger podadmin # keep its rootless podman socket alive
ssh tappaas@<vm-ip> 'sudo cat /etc/secrets/podman-admin'
```
Then open **`https://podman.<domain>`** (or `https://<vm-ip>:9090`), log in with that account,
and choose **Podman containers** in the left menu (Cockpit starts the user's podman service on
first use).
Username `admin`. This is the way back in when Authentik is unavailable — keep it.
## Using it
## Notes from the live installs (lab1, 2026-08-24/25)
- CLI: `ssh tappaas@<vm-ip>` then `podman run …`, `podman ps`, `podman images`.
- Compose files: `podman-compose up -d` in a directory with a `compose.yaml`.
- Web: manage/start/stop containers, pull images and inspect logs from the Cockpit
**Podman containers** page.
Four things bit during the first real install; all are handled in `update.sh` now, but they
are worth knowing when debugging:
## Verification
- **Portainer ≥ 2.39 requires a setup token.** `POST /api/users/admin/init` returns `403`
without an `X-Setup-Token` header carrying the token printed once at startup. The log line
puts ANSI colour codes between the key and the value, so the parser matches the 64-hex
token, not the `setup_token=` prefix.
- **The admin-init window closes.** A few minutes after start Portainer answers `303` with
`Redirect-Reason: AdminInitTimeout`. Any transient failure during that window would make the
module permanently un-bootstrappable, so `update.sh` restarts the container to reset the
timer and retries once.
- **Never gate the bootstrap on the password file existing.** A failed init leaves a password
behind; keying off it makes every later run skip the bootstrap and then fail to
authenticate. The guard is "can we log in?".
- **`proxyDomain` is not persisted into the installed config.** It lands as `null` despite
`copy-update-json.sh` documenting that `install-module.sh` computes it, so consumers must
derive it — from the module's **own** environment, never the default one.
## Verify
```bash
test-module.sh podman
module-manager module test podman-lab1 # effective name outside the default environment
```
Manual checks:
| Check | Expected |
|-------|----------|
| `ssh tappaas@<vm-ip> podman --version` | prints the Podman version |
| `https://podman.<domain>` from a `mgmt` browser | Cockpit login page loads |
| Same URL from an out-of-policy zone or the internet | 403 (blocked) |
| Cockpit → **Podman containers** after login | the Podman page renders |
## Troubleshooting
**Web login rejected for `tappaas`**
The account has no password by default — set one (Option A above) or use a dedicated admin
user (Option B). Cockpit cannot log in a key-only account.
**Podman page in Cockpit says the service is not running**
Cockpit starts the *per-user* podman service on first open; if it does not, on the VM run
`systemctl --user start podman.socket` as that user, and ensure `loginctl enable-linger <user>`
was set so the socket survives logout.
**Friendly name unreachable**
Confirm you are in the `mgmt` zone (others get 403 by design). Check the Caddy route:
`network:proxy test-service.sh podman`. Re-apply with `install-module.sh podman --force`.
**Friendly name loads a blank page (valid cert, no UI)**
Cockpit rides a WebSocket; behind a TLS reverse proxy Caddy must talk HTTP/1.1 to the upstream.
This module sets `proxyUpstreamHttp1: true` (os-caddy `HttpVersion=http1`). If you see a blank
page, verify the handler has HTTP Version = HTTP/1.1 and re-apply:
`install-module.sh podman --force`. The direct console `https://<vm-ip>:9090` always works.
**Re-run / upgrade**
`install-module.sh podman --force` is idempotent: apt re-runs (a no-op when already current)
and the version marker `/etc/tappaas-podman.version` is refreshed.
Checks podman itself, both sockets, the container, the API, and that authentication is still
OAuth (method `3`) rather than having fallen back to internal accounts.

View file

@ -1,40 +1,61 @@
# Podman — container host with a web console
# Podman — container host with a Portainer console
Primary audience: TAPPaaS operator who wants a plain place to run OCI containers.
Primary audience: TAPPaaS operator who wants a place to run OCI containers, and several
people managing them.
A **Debian 13 (trixie) VM with [Podman](https://podman.io) installed** — the daemonless,
rootless-capable container engine (a drop-in for `docker`). On top of the engine it adds the
**Cockpit web console** with the **`cockpit-podman`** plugin, so containers, images and pods
can be managed from a browser instead of only the CLI. Cockpit's console has a **login
screen**, so per the module brief it is published internally as `https://podman.<domain>` via
`network:proxy`.
A **Debian 13 (trixie) VM running [Podman](https://podman.io)** as the container engine, with
**[Portainer CE](https://www.portainer.io)** as its web interface. Portainer runs *as a
container on the very engine it manages*: Podman is the foundation, Portainer is the GUI.
## What you get
People sign in with their **TAPPaaS identity** over OIDC — no per-VM Linux accounts to hand
out. From the console they create, start, stop, exec into and inspect containers, pull images
and deploy compose stacks — on this VM, and on any other host in the zone running a Portainer
agent.
| Capability | Access from | How |
|------------|-------------|-----|
| Podman container engine (rootless + CLI) | on the VM | `ssh tappaas@<vm-ip>` then `podman …` |
| Cockpit web console (login + Podman page) | `mgmt` zone | `https://podman.<domain>` (internal) |
| Direct console | LAN reachable from `mgmt` | `https://<vm-ip>:9090` |
| `podman-compose` for compose files | on the VM | `podman-compose up -d` |
## Two engines, on purpose
## What is not included
| Socket | Who uses it | Why |
|--------|-------------|-----|
| **rootful** `/run/podman/podman.sock` | Portainer | It drives the Docker-compatible API; Portainer does not support rootless |
| **rootless** user socket (`tappaas`) | people who `ssh` in | The safer way to run day-to-day containers by hand |
- **No pre-loaded containers.** This is a clean host — you bring the images/compose files.
- **No internet exposure.** The friendly name is reachable only from the `mgmt` zone; it is
not published to the internet (no inbound NAT/port-forward).
- **No Docker daemon / Kubernetes.** Podman is daemonless; there is no `dockerd` and no k8s
control plane here (Podman can generate/play Kubernetes YAML, but that is out of scope).
- **No default web login.** The console authenticates against Linux (PAM) accounts on the VM;
the cloud-init `tappaas` user has no password until you set one — see [INSTALL.md](./INSTALL.md).
Portainer's access to the rootful socket is **root-equivalent on this VM**. That is inherent to
every container manager with socket access — treat the VM as a lab host and put nothing
precious on it.
## Access (internal only)
## Why Portainer and not Cockpit
`network:proxy` publishes **`https://podman.<domain>`** (split-horizon DNS → Caddy → the VM's
`:9090`, HTTPS upstream), with an access-list permitting only the **`mgmt`** zone — **not
reachable from the internet**. Cockpit rides WebSockets, so the route is created with
`proxyUpstreamHttp1: true` (Caddy talks HTTP/1.1 to the upstream); the direct
`https://<vm-ip>:9090` always works too.
The first version of this module shipped Cockpit with `cockpit-podman`. It cannot meet either
requirement: Cockpit is not an OIDC client and cannot be configured into one, it always needs
a local Unix account (`cockpit-bridge` runs in a PAM session as the user), and its multi-host
switcher is deprecated and disabled by default — the project states it "cannot be secure".
The full analysis, including the two options that were rejected, is in [DESIGN.md](./DESIGN.md).
[komodo](../komodo) is the GPL alternative that was weighed against Portainer.
## Gateway role
Given an explicit `proxyDomain`, this module publishes at a name of your choosing rather than
the usual `<module>.<environment-domain>`. Pointing it at the environment's own domain makes
it that environment's front door:
```bash
module-manager module add podman --environment lab1 --proxyDomain lab1.makerfloss.eu
```
…and the console answers at **`https://lab1.makerfloss.eu`** with no extra DNS label.
## Identity, and what CE cannot do
`identity:identity` creates the OIDC application in Authentik, binds it to the allowed groups
— that binding **is** the access gate, since Authentik fails open without one — and writes
`OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_DISCOVERY_URI` to `/etc/secrets/podman.env`.
`update.sh` reads the discovery document and PUTs the endpoints into Portainer's
`/api/settings`.
**Mapping Authentik groups onto Portainer teams is a Business Edition feature.** In CE
everyone who passes the Authentik binding lands with the same access. For a shared lab that is
the intent; if you need per-person isolation, CE will not give it to you.
## Dependencies
@ -43,18 +64,19 @@ reachable from the internet**. Cockpit rides WebSockets, so the route is created
| `cluster:vm` | Creates the Debian 13 VM from the cloud image |
| `templates:debian` | OS prep (apt update/upgrade + qemu-guest-agent) |
| `backup:vm` | Scheduled VM backup to PBS |
| `network:proxy` | Internal-only reverse proxy for `podman.<domain>` |
| `network:proxy` | The published HTTPS name, upstream on `:9443` |
| `identity:identity` | OIDC application, group binding, client credentials |
## Placement
The module sets no explicit `zone0`, so the VM is placed in the **default environment's zone**;
install it with `--environment <env>` to put it in a specific environment and its associated
zone instead (see [INSTALL.md](./INSTALL.md#placement--zone)).
No `zone0` is set, so the VM lands in the environment's zone (ADR-007 P5).
`proxyAllowedZones` is deliberately unset, which gives the internal default — every Active
service zone plus `home`, `work`, `mgmt` and the netbird overlay, but **not** the internet — so
members reach the console from a client zone while Authentik decides who gets in.
## Sizing
2 vCPU / **2 GB** RAM / 20 GB disk — enough for the engine, Cockpit and a handful of light
containers. Bump memory/disk to match whatever you actually run on it (container images and
volumes land on the VM disk, thin-provisioned on ZFS).
2 vCPU / 2 GB RAM / 32 GB disk. Podman and Portainer are small; the disk is for the images and
volumes of whatever gets run here.
For installation steps see [INSTALL.md](./INSTALL.md).

View file

@ -2,11 +2,12 @@
#
# podman module install — thin wrapper.
#
# The VM itself is created by the cluster:vm provider (Debian 13 cloud image),
# then OS-prepped by the templates:debian provider (apt update + qemu-guest-agent
# via update-os.sh). This script applies the module-specific step: installing
# Podman + the Cockpit web console onto the Debian VM. All real work lives in
# update.sh (install == update for this module).
# The VM is created by the cluster:vm provider (Debian 13 cloud image) and
# OS-prepped by templates:debian. identity:identity has, by this point, created
# the OIDC application in Authentik and written OIDC_CLIENT_ID /
# OIDC_CLIENT_SECRET / OIDC_DISCOVERY_URI to the VM's secrets env file. This
# script applies the module-specific step; all real work lives in update.sh
# (install == update for this module).
#
# Usage: install.sh <module-name>
#

View file

@ -1,25 +1,30 @@
{
"description": "Podman container host — a Debian 13 VM with rootless Podman + the Cockpit web console (cockpit-podman) for managing containers, images and pods from a browser",
"version": "0.1.0",
"description": "Podman container host with a Portainer CE web console — create, start, stop and inspect OCI containers on this VM and on agent hosts in the same zone, signed in with TAPPaaS identity",
"version": "0.2.0",
"appVersion": "5.4",
"releaseDate": "2026-08-18",
"releaseDate": "2026-08-25",
"maintainer": "@larsrossen",
"status": "Development",
"vmname": "podman",
"vmid": 812,
"vmtag": "TAPPaaS,Community,Containers",
"vmtag": "TAPPaaS,MakerFLOSS,Containers",
"ports": [
{ "port": 9090, "protocol": "TCP", "description": "Cockpit web console UI + login (HTTPS)" }
{ "port": 9443, "protocol": "TCP", "description": "Portainer web console (HTTPS)" },
{ "port": 8000, "protocol": "TCP", "description": "Edge agent tunnel (inbound from edge agents)" }
],
"dependsOn": ["cluster:vm", "templates:debian", "backup:vm", "network:proxy"],
"dependsOn": ["cluster:vm", "templates:debian", "backup:vm", "network:proxy", "identity:identity"],
"provides": [],
"identity": {
"oidcRedirectPaths": ["/"],
"secretsEnv": "/etc/secrets/podman.env"
},
"config": {
"cluster:vm": {
"bios": "seabios",
"ostype": "l26",
"cores": 2,
"memory": "2048",
"diskSize": "20G",
"diskSize": "32G",
"storage": "tanka1",
"imageType": "img",
"image": "debian-13-generic-amd64.qcow2",
@ -28,10 +33,8 @@
"bridge0": "lan"
},
"network:proxy": {
"proxyPort": 9090,
"proxyUpstreamTls": true,
"proxyUpstreamHttp1": true,
"proxyAllowedZones": ["mgmt"]
"proxyPort": 9443,
"proxyUpstreamTls": true
}
}
}

View file

@ -1,12 +1,13 @@
#!/usr/bin/env bash
#
# podman module test — health checks for the Podman container-host VM.
# podman module test — health checks for the Podman host and its Portainer GUI.
#
# Verifies (from tappaas-cicd, over the Proxmox guest agent + SSH):
# - the VM is reachable
# - podman is installed (version marker + working binary)
# - the cockpit-podman plugin is present
# - the Cockpit web console answers (HTTPS, :9090)
# - the rootful podman socket is active (this is Portainer's Docker API)
# - the portainer container is running
# - the API answers on :9443
# - authentication is set to OAuth, i.e. identity wiring survived
#
# Usage: test.sh <module-name>
#
@ -47,35 +48,48 @@ ok "VM IP resolved (${IP})"
vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; }
# Version marker + working podman binary
GOT_VER="$(vm "cat /etc/tappaas-podman.version 2>/dev/null" || true)"
if [[ -n "${GOT_VER}" ]]; then
ok "podman version marker present (${GOT_VER})"
else
no "podman version marker missing (not installed?)"
fi
# The engine itself, and the Docker-compatible API Portainer drives.
if vm "command -v podman >/dev/null 2>&1 && podman --version >/dev/null 2>&1"; then
ok "podman binary works ($(vm "podman --version 2>/dev/null" || echo '?'))"
ok "podman works ($(vm "podman --version 2>/dev/null" | tr -d '\r'))"
else
no "podman binary not working"
fi
# cockpit-podman plugin installed
if vm "dpkg -s cockpit-podman >/dev/null 2>&1"; then
ok "cockpit-podman plugin installed"
if vm "systemctl is-active --quiet podman.socket"; then
ok "rootful podman.socket active (Portainer's container API)"
else
no "cockpit-podman plugin not installed"
no "podman.socket not active (Portainer has no container API without it)"
fi
if vm "systemctl --user is-active --quiet podman.socket"; then
ok "rootless podman.socket active (for CLI users)"
else
no "rootless podman.socket not active"
fi
# Cockpit web console answers on :9090 (login page pre-auth is a pass).
# Plain -s (no --fail): --fail exits 22 on 4xx and would corrupt the -w code.
code="$(vm "curl -sk -o /dev/null -w '%{http_code}' https://localhost:9090/ 2>/dev/null" || echo 000)"
if [[ "${code}" =~ ^(200|302|401|403)$ ]]; then
ok "Cockpit console answers on :9090 (HTTP ${code})"
# Container up and healthy.
STATE="$(vm "sudo podman container inspect --format '{{.State.Status}}' portainer 2>/dev/null" | tr -d '\r' || true)"
if [[ "${STATE}" == "running" ]]; then
ok "portainer container running"
else
no "Cockpit console did not answer on :9090 (HTTP ${code})"
no "portainer container not running (state: ${STATE:-absent})"
fi
# API answers. /api/status is unauthenticated and returns the version.
VER="$(vm "curl -fsk https://localhost:9443/api/status 2>/dev/null" | jq -r '.Version // empty' || true)"
if [[ -n "${VER}" ]]; then
ok "API answers on :9443 (Portainer ${VER})"
else
no "API did not answer on :9443"
fi
# Identity wiring. /api/settings/public is unauthenticated and carries
# AuthenticationMethod: 1 internal, 2 LDAP, 3 OAuth.
METHOD="$(vm "curl -fsk https://localhost:9443/api/settings/public 2>/dev/null" | jq -r '.AuthenticationMethod // empty' || true)"
case "${METHOD}" in
3) ok "authentication is OAuth (TAPPaaS identity)" ;;
"") no "could not read /api/settings/public" ;;
*) no "authentication is method ${METHOD}, expected 3 (OAuth) — identity wiring missing" ;;
esac
echo ""
info "Result: ${PASS} passed, ${FAIL} failed"
[[ "${FAIL}" -eq 0 ]]

View file

@ -1,19 +1,23 @@
#!/usr/bin/env bash
#
# podman module update — install/upgrade Podman + the Cockpit web console on the
# Debian 13 VM.
# podman module update — a Podman container host with Portainer CE as its GUI.
#
# Podman is the daemonless, rootless-capable container engine; it ships in the
# Debian 13 (trixie) main repo, so no external download is needed. To give it the
# browser interface the module promises, we also install Cockpit and its
# cockpit-podman plugin — Cockpit serves an HTTPS admin console with a PAM LOGIN
# SCREEN on :9090, and cockpit-podman adds the "Podman containers" page for
# managing containers, images and pods. network:proxy publishes that console as
# https://podman.<domain> (internal, mgmt zone only).
# Podman is the engine; Portainer is the web interface onto it. Both live on the
# same VM: Portainer runs AS a container on the very engine it manages.
#
# Portainer manages containers through the Docker-compatible API. Podman exposes
# exactly that API on its ROOTFUL socket (/run/podman/podman.sock), which is what
# Portainer's own Podman install guide uses — rootless Podman is explicitly not
# supported by Portainer, so this module runs the system socket.
#
# Login is OIDC against Authentik: identity:identity created the application and
# wrote the client credentials to ${SECRETS_ENV} on the VM before this ran. We
# read the discovery document to find the three endpoints Portainer wants, then
# PUT them into /api/settings. Portainer has no config file for this — the API is
# the only way to configure it unattended.
#
# This runs on tappaas-cicd: it resolves the VM's IP via the Proxmox guest agent,
# then over SSH installs the packages from apt. Idempotent: it re-runs apt every
# time (apt is a no-op when already current) and records a version marker.
# then drives the VM over SSH. Idempotent throughout.
#
# Usage: update.sh <module-name>
#
@ -24,12 +28,15 @@ set -euo pipefail
MODULE="${1:-podman}"
readonly MGMT="mgmt"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly MARKER="/etc/tappaas-podman.version" # on the VM: records installed podman version
readonly IMAGE="docker.io/portainer/portainer-ce:lts"
readonly CONTAINER="portainer"
readonly MARKER="/etc/tappaas-podman.version" # on the VM: image ID we installed
readonly ADMIN_SECRET="/etc/secrets/podman-admin" # on the VM: bootstrap admin password
VMNAME="$(get_config_value 'vmname' "${MODULE}")"
VMID="$(get_config_value 'vmid')"
SECRETS_ENV="$(get_config_value 'secretsEnv' '/etc/secrets/podman.env')"
[[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}"
# ── Locate the node hosting the VM (HA-safe) and resolve its IP ───────
@ -47,78 +54,214 @@ get_vm_ip() {
| grep -v '^127\.' | head -1
}
info "${BOLD}Installing Podman + Cockpit${CL} on ${VMNAME} (VM ${VMID}, node ${NODE})"
info "${BOLD}Installing Podman + Portainer${CL} on ${VMNAME} (VM ${VMID}, node ${NODE})"
IP=""
for _ in $(seq 1 18); do IP="$(get_vm_ip)"; [[ -n "${IP}" ]] && break; sleep 10; done
[[ -n "${IP}" ]] || die "could not resolve ${VMNAME} IP via guest agent (is qemu-guest-agent up? templates:debian installs it)"
info " VM IP: ${IP}"
# SSH helper: run a command on the VM as the tappaas user.
vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; }
# Wait for SSH (cloud-init may still be finishing).
for _ in $(seq 1 40); do vm "exit 0" 2>/dev/null && break; sleep 3; done
vm "exit 0" 2>/dev/null || die "SSH to tappaas@${IP} not available"
# ── Friendly URL for the operator ────────────────────────────────────
DOMAIN="$(get_variant_config "" 2>/dev/null | jq -r '.domain // empty' || true)"
# proxyDomain is NOT persisted into the installed config by install-module.sh, so
# it is normally empty here and we must derive it — from the module's OWN
# environment, not the default one (identity/install-service.sh does the same).
ENVIRONMENT="$(get_config_value 'environment' '')"
DOMAIN="$(get_variant_config "${ENVIRONMENT}" 2>/dev/null | jq -r '.domain // empty' || true)"
PROXY_DOMAIN="$(get_config_value 'proxyDomain' "${VMNAME}${DOMAIN:+.${DOMAIN}}")"
COCKPIT_DIRECT_URL="https://${IP}:9090"
UI_DIRECT_URL="https://${IP}:9443"
if [[ -n "${PROXY_DOMAIN}" ]] && getent hosts "${PROXY_DOMAIN}" >/dev/null 2>&1; then
COCKPIT_URL="https://${PROXY_DOMAIN}"
UI_URL="https://${PROXY_DOMAIN}"
else
[[ -n "${PROXY_DOMAIN}" ]] && info " ${PROXY_DOMAIN} does not resolve yet — using the direct URL"
COCKPIT_URL="${COCKPIT_DIRECT_URL}"
UI_URL="${UI_DIRECT_URL}"
fi
# ── 1. Install podman + cockpit + cockpit-podman (apt) ───────────────
# podman-compose/slirp4netns give rootless networking + compose-file support;
# cockpit + cockpit-podman provide the web console with a login screen on :9090.
info " Installing podman, podman-compose, cockpit, cockpit-podman (apt)..."
# ── 1. Podman engine + the rootful API socket ────────────────────────
info " Installing podman + tooling (apt)..."
vm "sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq" || die "apt-get update failed"
vm "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
podman podman-compose slirp4netns uidmap \
cockpit cockpit-podman \
curl ca-certificates" \
|| die "failed to install podman/cockpit packages"
podman podman-compose slirp4netns uidmap curl jq ca-certificates" \
|| die "failed to install podman"
PODMAN_VER="$(vm "podman --version 2>/dev/null | awk '{print \$3}'" || true)"
[[ -n "${PODMAN_VER}" ]] || die "podman did not install correctly (no version reported)"
info " podman version: ${PODMAN_VER}"
# ── 2. Enable podman socket + Cockpit web console ────────────────────
# Rootless podman API socket for the tappaas user (lets cockpit-podman talk to it
# without root), and the Cockpit HTTPS console on :9090.
info " Enabling the rootless podman socket + Cockpit web console..."
vm "systemctl --user enable --now podman.socket 2>/dev/null || true"
vm "sudo loginctl enable-linger tappaas 2>/dev/null || true" # keep the user socket alive after logout
vm "sudo systemctl enable --now cockpit.socket" || die "failed to enable cockpit.socket"
# Record version marker.
vm "echo '${PODMAN_VER}' | sudo tee ${MARKER} >/dev/null" || warn "could not write version marker"
# ── 3. Wait for the Cockpit console to answer (port 9090) ────────────
info " Waiting for the Cockpit console to come up (https://${IP}:9090)..."
# Two engines on one host, on purpose:
# - the ROOTFUL socket is the Docker-compatible API Portainer drives (Portainer
# does not support rootless);
# - the ROOTLESS user socket is for people who ssh in and run podman themselves,
# which is the safer way to run day-to-day containers.
# podman-restart.service is what makes --restart=always survive a reboot.
vm "sudo systemctl enable --now podman.socket" || die "failed to enable podman.socket"
vm "sudo systemctl enable --now podman-restart.service 2>/dev/null || true"
vm "systemctl --user enable --now podman.socket 2>/dev/null || true"
vm "sudo loginctl enable-linger tappaas 2>/dev/null || true"
# ── 2. Portainer container ───────────────────────────────────────────
info " Pulling ${IMAGE}..."
vm "sudo podman pull -q ${IMAGE}" >/dev/null || die "failed to pull ${IMAGE}"
IMAGE_ID="$(vm "sudo podman image inspect --format '{{.Id}}' ${IMAGE}" | tr -d '\r')"
[[ -n "${IMAGE_ID}" ]] || die "could not read the image id for ${IMAGE}"
RUNNING_ID="$(vm "sudo podman container inspect --format '{{.Image}}' ${CONTAINER} 2>/dev/null" | tr -d '\r' || true)"
if [[ "${RUNNING_ID}" != "${IMAGE_ID}" ]]; then
# Recreate only when the image actually changed. The named volume carries all
# state, so removing the container loses nothing.
info " (Re)creating the ${CONTAINER} container..."
vm "sudo podman volume create portainer_data >/dev/null 2>&1 || true"
vm "sudo podman rm -f ${CONTAINER} >/dev/null 2>&1 || true"
vm "sudo podman run -d --name ${CONTAINER} --restart=always --privileged \
-p 8000:8000 -p 9443:9443 \
-v /run/podman/podman.sock:/var/run/docker.sock \
-v portainer_data:/data ${IMAGE}" \
|| die "failed to start ${CONTAINER}"
vm "echo '${IMAGE_ID}' | sudo tee /etc/tappaas-portainer.image >/dev/null" || warn "could not write image marker"
else
info " ${CONTAINER} already running the current image — leaving it alone"
fi
# ── 3. Wait for the API ──────────────────────────────────────────────
info " Waiting for Portainer to answer on :9443..."
UP=0
for _ in $(seq 1 18); do
code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9090/ 2>/dev/null" || echo 000)"
[[ "${code}" =~ ^(200|302|401|403)$ ]] && { UP=1; break; }
for _ in $(seq 1 24); do
code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)"
[[ "${code}" == "200" ]] && { UP=1; break; }
sleep 5
done
[[ "${UP}" -eq 1 ]] || die "Portainer did not answer on :9443"
echo ""
if [[ "${UP}" -eq 1 ]]; then
info "${GN}✓ Podman ${PODMAN_VER} + Cockpit console installed${CL}"
# ── 4. Bootstrap the local admin ─────────────────────────────────────
# The password is kept on the VM for break-glass access — OIDC is the everyday
# path, this is the account that survives an Authentik outage.
#
# The guard is "can we log in?", NOT "does the password file exist": a failed
# init leaves a password file behind, and keying off the file makes every later
# run skip the bootstrap forever (seen on the first lab1 install).
if ! vm "sudo test -s ${ADMIN_SECRET}" 2>/dev/null; then
vm "sudo install -d -m 0700 \$(dirname ${ADMIN_SECRET})"
vm "openssl rand -base64 24 | sudo tee ${ADMIN_SECRET} >/dev/null && sudo chmod 0600 ${ADMIN_SECRET}"
fi
ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')"
portainer_jwt() {
vm "curl -sk -X POST https://localhost:9443/api/auth \
-H 'Content-Type: application/json' \
-d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null | jq -r '.jwt // empty'
}
wait_for_api() {
local code
for _ in $(seq 1 24); do
code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)"
[[ "${code}" == "200" ]] && return 0
sleep 5
done
return 1
}
# Echoes the HTTP status of an admin-init attempt. The setup token is minted
# afresh at every container start, so re-read it each time.
init_admin() {
local tok hdr=""
tok="$(vm "sudo podman logs ${CONTAINER} 2>&1 | grep setup_token | grep -oE '[0-9a-f]{64}' | tail -1" | tr -d '\r')"
[[ -n "${tok}" ]] && hdr="-H 'X-Setup-Token: ${tok}'"
vm "curl -sk -o /dev/null -w '%{http_code}' -X POST https://localhost:9443/api/users/admin/init \
-H 'Content-Type: application/json' ${hdr} \
-d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null || echo 000
}
JWT="$(portainer_jwt)"
if [[ -z "${JWT}" ]]; then
info " Bootstrapping the break-glass admin account..."
init_code="$(init_admin)"
if [[ "${init_code}" == "303" ]]; then
# Portainer closes the admin-init window a few minutes after start
# (Redirect-Reason: AdminInitTimeout). Without this the module can never
# be bootstrapped again after any transient failure — restarting resets
# the timer and mints a new setup token.
info " admin-init window had closed — restarting ${CONTAINER} to reopen it"
vm "sudo podman restart ${CONTAINER} >/dev/null" || warn " restart failed"
wait_for_api && init_code="$(init_admin)"
fi
case "${init_code}" in
200|204) info " admin created (password in ${ADMIN_SECRET} on the VM)" ;;
409) warn " an admin exists but ${ADMIN_SECRET} does not match it — reset it by hand" ;;
*) warn " admin init returned HTTP ${init_code}; configure the admin by hand at ${UI_URL}" ;;
esac
JWT="$(portainer_jwt)"
else
warn "Packages installed but the Cockpit console did not answer yet — it may still be starting."
info " break-glass admin already provisioned"
fi
# ── 5. Point Portainer at TAPPaaS identity (OIDC) ────────────────────
# identity:identity wrote these three values; if they are missing the module is
# still usable with the local admin, so warn rather than fail.
if [[ -z "${JWT:-}" ]]; then
warn " no admin session — skipping OIDC configuration (see the admin init warning above)"
elif ! vm "sudo test -s ${SECRETS_ENV}" 2>/dev/null; then
warn " ${SECRETS_ENV} missing — is identity:identity in dependsOn?"
else
info " Configuring OIDC login against Authentik..."
CLIENT_ID="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_ID\"'" | tr -d '\r')"
CLIENT_SECRET="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_SECRET\"'" | tr -d '\r')"
DISCOVERY="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_DISCOVERY_URI\"'" | tr -d '\r')"
if [[ -n "${CLIENT_ID}" && -n "${CLIENT_SECRET}" && -n "${DISCOVERY}" ]]; then
# Portainer wants the three endpoints separately; the discovery document has them.
WELL_KNOWN="$(vm "curl -fsk ${DISCOVERY}" || true)"
AUTH_URI="$(jq -r '.authorization_endpoint // empty' <<<"${WELL_KNOWN}")"
TOKEN_URI="$(jq -r '.token_endpoint // empty' <<<"${WELL_KNOWN}")"
USER_URI="$(jq -r '.userinfo_endpoint // empty' <<<"${WELL_KNOWN}")"
if [[ -n "${AUTH_URI}" && -n "${TOKEN_URI}" && -n "${USER_URI}" ]]; then
if [[ -n "${JWT}" ]]; then
# AuthenticationMethod 3 = OAuth. OAuthAutoCreateUsers lets a
# TAPPaaS identity log in without an admin pre-creating it;
# Authentik's group binding is the actual access gate.
SETTINGS="$(jq -n \
--arg cid "${CLIENT_ID}" --arg csec "${CLIENT_SECRET}" \
--arg auth "${AUTH_URI}" --arg tok "${TOKEN_URI}" --arg usr "${USER_URI}" \
--arg redir "https://${PROXY_DOMAIN}/" \
'{AuthenticationMethod: 3,
OAuthSettings: {ClientID: $cid, ClientSecret: $csec,
AuthorizationURI: $auth, AccessTokenURI: $tok,
ResourceURI: $usr, RedirectURI: $redir,
UserIdentifier: "preferred_username",
Scopes: "openid profile email",
OAuthAutoCreateUsers: true, SSO: true}}')"
set_code="$(vm "curl -sk -o /dev/null -w '%{http_code}' -X PUT https://localhost:9443/api/settings \
-H 'Authorization: Bearer ${JWT}' -H 'Content-Type: application/json' \
-d '$(printf '%s' "${SETTINGS}" | tr -d '\n')'" || echo 000)"
if [[ "${set_code}" == "200" ]]; then
info " ${GN}✓${CL} OIDC login configured"
else
warn " settings update returned HTTP ${set_code} — configure OAuth by hand at ${UI_URL}"
fi
else
warn " could not authenticate as the local admin — skipping OIDC configuration"
fi
else
warn " discovery document at ${DISCOVERY} lacked the endpoints — skipping OIDC configuration"
fi
else
warn " ${SECRETS_ENV} did not carry all three OIDC values — skipping OIDC configuration"
fi
fi
echo ""
info "${GN}✓ Podman + Portainer installed${CL}"
echo ""
info "${BOLD}═══ Next steps ═══${CL}"
info " ${BOLD}Console:${CL} ${COCKPIT_URL} (direct: https://${IP}:9090)"
info " Log in with a Linux account on the VM. The cloud-init ${BOLD}tappaas${CL} user has"
info " SSH-key auth and no password, so set one first for the web login:"
info " ssh tappaas@${IP} 'sudo passwd tappaas'"
info " Then open the console and pick ${BOLD}Podman containers${CL} in the left menu."
info " See INSTALL.md for the details (and how to add a dedicated admin user instead)."
info " ${BOLD}Console:${CL} ${UI_URL} (direct: ${UI_DIRECT_URL})"
info " Sign in with your TAPPaaS identity. Break-glass admin password:"
info " ssh tappaas@${IP} 'sudo cat ${ADMIN_SECRET}'"
info " Containers on this host: ssh tappaas@${IP} and run podman (rootless)."
info " Other hosts in the zone: run the Portainer agent there, add it under"
info " Environments — see INSTALL.md."

View file

@ -1,91 +0,0 @@
# Portainer CE — Installation
Primary audience: TAPPaaS admin. Steps the scripts cannot automate.
## Prerequisites
- `tappaas@tappaas-cicd` with the usual cluster SSH/sudo access.
- Dependencies resolve automatically (`cluster:vm`, `templates:debian`, `backup:vm`,
`network:proxy`, `identity:identity`).
- The identity foundation must be up — this module's login depends on it.
- For a valid certificate on the friendly name, the TAPPaaS wildcard must be in OPNsense
Trust (`acme-setup.sh`, once — see the platform INSTALL).
## Install
```bash
module-manager module add portainer --environment <env>
```
This automatically:
- creates a Debian 13 VM (vmid 813, 2 vCPU / 2 GB / 32 GB) and OS-preps it;
- installs `podman` and enables the **rootful** `podman.socket` plus `podman-restart.service`;
- runs the `portainer-ce:lts` container with the socket and a named volume;
- creates a break-glass local admin, storing its password at `/etc/secrets/portainer-admin`;
- reads the OIDC credentials `identity:identity` left in `/etc/secrets/portainer.env`,
resolves the endpoints from the discovery document, and switches Portainer to OAuth login;
- publishes `https://portainer.<domain>` through the reverse proxy.
## Post-install (manual)
### 1. Check who can log in
Authentik binds the application to the allowed groups at install. Anyone in those groups can
sign in; **anyone not in them cannot**, which is the access gate. To let the hackerspace in:
```bash
people-manager user modify <user> --add-groups devops
people-manager reconcile --apply
```
### 2. Add other hosts in the zone
On each additional VM or physical machine, run the agent:
```bash
podman run -d --name portainer_agent --restart=always \
-p 9001:9001 \
-v /run/podman/podman.sock:/var/run/docker.sock \
-v /var/lib/docker/volumes:/var/lib/docker/volumes \
docker.io/portainer/agent:lts
```
Then in Portainer: **Environments → Add environment → Docker Standalone → Agent**, address
`<host>:9001`. Same-zone traffic is allowed by default; a host in a different zone needs a
pinhole into `:9001`.
> Portainer now describes this classic agent as legacy and prefers the Edge Agent, which
> dials out to `:8000` instead of being dialled into. For hosts on the lab LAN the classic
> agent is simpler; use Edge if a host sits behind NAT.
### 3. Break-glass access
If Authentik is down, log in locally:
```bash
ssh tappaas@<vm-ip> 'sudo cat /etc/secrets/portainer-admin'
```
Username `admin`. Keep this — it is the only way back in when identity is unavailable.
## Notes from the first live install (2026-08-24, lab1)
- **Portainer ≥ 2.39 requires a setup token.** `POST /api/users/admin/init` returns `403`
unless the `X-Setup-Token` header carries the token Portainer prints once at startup.
`update.sh` now reads it from `podman logs`. The log line wraps the value in ANSI colour
codes, so it matches the 64-hex token rather than the `setup_token=` prefix.
- **`proxyDomain` is not persisted into the installed config.** `copy-update-json.sh`
documents that `install-module.sh` computes it from the environment, but it lands as
`null`, so every consumer has to derive it — and must do so from the module's *own*
environment. The published name is `<effective-module-name>.<environment-domain>`, e.g.
**`portainer-lab1.lab1.makerfloss.eu`** — not `portainer.lab1.…`.
## Verify
```bash
module-manager module test portainer
```
Checks the podman socket, the container, the API, and that authentication is still OAuth
(method `3`) rather than having fallen back to internal accounts.

View file

@ -1,84 +0,0 @@
# Portainer CE — container management with TAPPaaS login
Primary audience: TAPPaaS operator who wants **several people** creating, starting, stopping
and inspecting containers — on this VM and on other hosts in the same zone.
A **Debian 13 (trixie) VM running [Portainer CE](https://www.portainer.io)** on top of rootful
Podman. Portainer is a web console for OCI containers: create, start, stop, exec, read logs,
inspect, pull images, deploy compose stacks. People sign in with their **TAPPaaS identity**
over OIDC — no per-VM Linux accounts.
> **Status: Development, and not yet run end to end on a live TAPPaaS.** Written against the
> published Portainer install docs and API, and against the `identity:identity` contract, but
> the first real install is still ahead.
## What you get
| Capability | Access from | How |
|------------|-------------|-----|
| Manage containers on this VM | any internal zone | `https://portainer.<domain>` |
| Manage containers on other lab hosts | same | add each host as an Environment (agent on `:9001`) |
| Sign-in with your TAPPaaS account | same | OIDC against Authentik |
| Break-glass local admin | the VM | password in `/etc/secrets/portainer-admin` |
## Why not Cockpit
The obvious alternative — Cockpit with `cockpit-podman` — cannot meet either requirement.
Cockpit is not an OIDC client and cannot be configured into one, and it always needs a local
Unix account because `cockpit-bridge` runs in a PAM session as the user. Its multi-host host
switcher is **deprecated and disabled by default**, and the project states it "cannot be
secure" because a remote host's JavaScript then runs against every other connected host. See
[podman/DESIGN.md](../podman/DESIGN.md) for the full analysis, and [komodo](../komodo) for the
GPL alternative that was weighed against this one.
## Rootful, deliberately
Portainer drives containers through the **Docker-compatible API**, which Podman serves on its
rootful socket `/run/podman/podman.sock`. Portainer's documentation says rootless Podman "may
work but is currently not officially supported", so this module enables the system socket.
That means **Portainer has root-equivalent control of this VM** — as any container manager
with socket access does. The VM is therefore a lab host and nothing else: do not co-locate
anything you care about.
## Multi-host
The Portainer **agent** is one container on each additional host, listening on `:9001`, which
the server then adds as an Environment. Hosts in the same zone reach each other directly;
crossing a zone needs a pinhole. See [INSTALL.md](./INSTALL.md).
## Identity, and what CE can't do
`identity:identity` creates the OIDC application in Authentik, binds it to the allowed groups
— that binding **is** the access gate, since Authentik fails open without one — and writes
`OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_DISCOVERY_URI` to `/etc/secrets/portainer.env`.
`update.sh` reads the discovery document and PUTs the three endpoints into `/api/settings`.
**Mapping Authentik groups onto Portainer teams is a Business Edition feature.** In CE every
person who passes the Authentik binding lands with the same access. For a shared lab that is
the intent; if you need per-person isolation, CE will not give it to you.
## Dependencies
| Depends on | Purpose |
|------------|---------|
| `cluster:vm` | Creates the Debian 13 VM from the cloud image |
| `templates:debian` | OS prep (apt update/upgrade + qemu-guest-agent) |
| `backup:vm` | Scheduled VM backup to PBS |
| `network:proxy` | `https://portainer.<domain>`, HTTPS upstream on `:9443` |
| `identity:identity` | OIDC application, group binding, client credentials |
## Placement
No `zone0` is set, so the VM lands in the environment's zone (ADR-007 P5). Install with
`--environment <env>` to place it. `proxyAllowedZones` is deliberately **unset**, which gives
the internal default — every Active service zone plus `home`, `work`, `mgmt` and the netbird
overlay, but **not** the internet — so members can reach the console from a client zone while
Authentik gates who actually gets in.
## Sizing
2 vCPU / 2 GB RAM / 32 GB disk. Portainer itself is tiny; the disk is for the images and
volumes of whatever gets run here.
For installation steps see [INSTALL.md](./INSTALL.md).

View file

@ -1,18 +0,0 @@
#!/usr/bin/env bash
#
# portainer module install — thin wrapper.
#
# The VM is created by the cluster:vm provider (Debian 13 cloud image) and
# OS-prepped by templates:debian. identity:identity has, by this point, created
# the OIDC application in Authentik and written OIDC_CLIENT_ID /
# OIDC_CLIENT_SECRET / OIDC_DISCOVERY_URI to the VM's secrets env file. This
# script applies the module-specific step; all real work lives in update.sh
# (install == update for this module).
#
# Usage: install.sh <module-name>
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/update.sh" "$@"

View file

@ -1,40 +0,0 @@
{
"description": "Portainer CE — web console for creating, starting, stopping and inspecting OCI containers on this VM and on agent hosts in the same zone, with TAPPaaS identity login over OIDC",
"version": "0.1.0",
"appVersion": "lts",
"releaseDate": "2026-08-23",
"maintainer": "@larsrossen",
"status": "Development",
"vmname": "portainer",
"vmid": 813,
"vmtag": "TAPPaaS,MakerFLOSS,Containers",
"ports": [
{ "port": 9443, "protocol": "TCP", "description": "Portainer web UI (HTTPS)" },
{ "port": 8000, "protocol": "TCP", "description": "Edge agent tunnel (inbound from edge agents)" }
],
"dependsOn": ["cluster:vm", "templates:debian", "backup:vm", "network:proxy", "identity:identity"],
"provides": [],
"identity": {
"oidcRedirectPaths": ["/"],
"secretsEnv": "/etc/secrets/portainer.env"
},
"config": {
"cluster:vm": {
"bios": "seabios",
"ostype": "l26",
"cores": 2,
"memory": "2048",
"diskSize": "32G",
"storage": "tanka1",
"imageType": "img",
"image": "debian-13-generic-amd64.qcow2",
"imageLocation": "https://cdimage.debian.org/cdimage/cloud/trixie/latest",
"cloudInit": "true",
"bridge0": "lan"
},
"network:proxy": {
"proxyPort": 9443,
"proxyUpstreamTls": true
}
}
}

View file

@ -1,85 +0,0 @@
#!/usr/bin/env bash
#
# portainer module test — health checks for the Portainer CE VM.
#
# Verifies (from tappaas-cicd, over the Proxmox guest agent + SSH):
# - the VM is reachable
# - the rootful podman socket is active (this is Portainer's Docker API)
# - the portainer container is running
# - the API answers on :9443
# - authentication is set to OAuth, i.e. identity wiring survived
#
# Usage: test.sh <module-name>
#
set -uo pipefail
. /home/tappaas/bin/common-install-routines.sh
MODULE="${1:-portainer}"
readonly MGMT="mgmt"
VMID="$(get_config_value 'vmid')"
VMNAME="$(get_config_value 'vmname' "${MODULE}")"
PASS=0; FAIL=0
ok() { info " ${GN}✓${CL} $1"; PASS=$((PASS+1)); }
no() { error " ✗ $1"; FAIL=$((FAIL+1)); }
[[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}"
PRIMARY="$(get_primary_node_fqdn 2>/dev/null || echo "tappaas1.${MGMT}.internal")"
NODE="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${PRIMARY}" \
"pvesh get /cluster/resources --type vm --output-format json 2>/dev/null" \
| jq -r --arg v "${VMID}" '.[] | select(.vmid==($v|tonumber)) | .node' 2>/dev/null | head -1)"
[[ -n "${NODE}" ]] || NODE="$(get_config_value 'node' "$(get_node_hostname 0)")"
IP="$(ssh -o BatchMode=yes "root@${NODE}.${MGMT}.internal" \
"qm guest cmd ${VMID} network-get-interfaces" 2>/dev/null \
| jq -r '.[] | select(.name | test("^lo$") | not) | ."ip-addresses"[]? | select(."ip-address-type"=="ipv4") | ."ip-address"' 2>/dev/null \
| grep -v '^127\.' | head -1)"
info "${BOLD}portainer test${CL} (${VMNAME}, VM ${VMID} on ${NODE})"
if [[ -z "${IP}" ]]; then
no "could not resolve VM IP via guest agent"
info "Result: ${PASS} passed, ${FAIL} failed"
exit 1
fi
ok "VM IP resolved (${IP})"
vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; }
# The Docker-compatible API Portainer drives.
if vm "systemctl is-active --quiet podman.socket"; then
ok "rootful podman.socket active"
else
no "podman.socket not active (Portainer has no container API without it)"
fi
# Container up and healthy.
STATE="$(vm "sudo podman container inspect --format '{{.State.Status}}' portainer 2>/dev/null" | tr -d '\r' || true)"
if [[ "${STATE}" == "running" ]]; then
ok "portainer container running"
else
no "portainer container not running (state: ${STATE:-absent})"
fi
# API answers. /api/status is unauthenticated and returns the version.
VER="$(vm "curl -fsk https://localhost:9443/api/status 2>/dev/null" | jq -r '.Version // empty' || true)"
if [[ -n "${VER}" ]]; then
ok "API answers on :9443 (Portainer ${VER})"
else
no "API did not answer on :9443"
fi
# Identity wiring. /api/settings/public is unauthenticated and carries
# AuthenticationMethod: 1 internal, 2 LDAP, 3 OAuth.
METHOD="$(vm "curl -fsk https://localhost:9443/api/settings/public 2>/dev/null" | jq -r '.AuthenticationMethod // empty' || true)"
case "${METHOD}" in
3) ok "authentication is OAuth (TAPPaaS identity)" ;;
"") no "could not read /api/settings/public" ;;
*) no "authentication is method ${METHOD}, expected 3 (OAuth) — identity wiring missing" ;;
esac
echo ""
info "Result: ${PASS} passed, ${FAIL} failed"
[[ "${FAIL}" -eq 0 ]]

View file

@ -1,252 +0,0 @@
#!/usr/bin/env bash
#
# portainer module update — install/upgrade Portainer CE on the Debian 13 VM and
# point it at TAPPaaS identity for login.
#
# Portainer manages containers through the Docker-compatible API. Podman exposes
# exactly that API on its ROOTFUL socket (/run/podman/podman.sock), which is what
# Portainer's own Podman install guide uses — rootless Podman is explicitly not
# supported by Portainer, so this module runs the system socket.
#
# Login is OIDC against Authentik: identity:identity created the application and
# wrote the client credentials to ${SECRETS_ENV} on the VM before this ran. We
# read the discovery document to find the three endpoints Portainer wants, then
# PUT them into /api/settings. Portainer has no config file for this — the API is
# the only way to configure it unattended.
#
# This runs on tappaas-cicd: it resolves the VM's IP via the Proxmox guest agent,
# then drives the VM over SSH. Idempotent throughout.
#
# Usage: update.sh <module-name>
#
set -euo pipefail
. /home/tappaas/bin/common-install-routines.sh
MODULE="${1:-portainer}"
readonly MGMT="mgmt"
readonly IMAGE="docker.io/portainer/portainer-ce:lts"
readonly CONTAINER="portainer"
readonly MARKER="/etc/tappaas-portainer.version" # on the VM: image ID we installed
readonly ADMIN_SECRET="/etc/secrets/portainer-admin" # on the VM: bootstrap admin password
VMNAME="$(get_config_value 'vmname' "${MODULE}")"
VMID="$(get_config_value 'vmid')"
SECRETS_ENV="$(get_config_value 'secretsEnv' '/etc/secrets/portainer.env')"
[[ -n "${VMID}" && "${VMID}" != "null" ]] || die "no vmid for ${MODULE}"
# ── Locate the node hosting the VM (HA-safe) and resolve its IP ───────
PRIMARY="$(get_primary_node_fqdn 2>/dev/null || echo "tappaas1.${MGMT}.internal")"
NODE="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${PRIMARY}" \
"pvesh get /cluster/resources --type vm --output-format json 2>/dev/null" \
| jq -r --arg v "${VMID}" '.[] | select(.vmid==($v|tonumber)) | .node' 2>/dev/null | head -1)"
[[ -n "${NODE}" ]] || NODE="$(get_config_value 'node' "$(get_node_hostname 0)")"
[[ -n "${NODE}" ]] || die "could not locate the node hosting VM ${VMID}"
get_vm_ip() {
ssh -o BatchMode=yes "root@${NODE}.${MGMT}.internal" \
"qm guest cmd ${VMID} network-get-interfaces" 2>/dev/null \
| jq -r '.[] | select(.name | test("^lo$") | not) | ."ip-addresses"[]? | select(."ip-address-type"=="ipv4") | ."ip-address"' 2>/dev/null \
| grep -v '^127\.' | head -1
}
info "${BOLD}Installing Portainer CE${CL} on ${VMNAME} (VM ${VMID}, node ${NODE})"
IP=""
for _ in $(seq 1 18); do IP="$(get_vm_ip)"; [[ -n "${IP}" ]] && break; sleep 10; done
[[ -n "${IP}" ]] || die "could not resolve ${VMNAME} IP via guest agent (is qemu-guest-agent up? templates:debian installs it)"
info " VM IP: ${IP}"
vm() { ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "tappaas@${IP}" "$@"; }
for _ in $(seq 1 40); do vm "exit 0" 2>/dev/null && break; sleep 3; done
vm "exit 0" 2>/dev/null || die "SSH to tappaas@${IP} not available"
# ── Friendly URL for the operator ────────────────────────────────────
# proxyDomain is NOT persisted into the installed config by install-module.sh, so
# it is normally empty here and we must derive it — from the module's OWN
# environment, not the default one (identity/install-service.sh does the same).
ENVIRONMENT="$(get_config_value 'environment' '')"
DOMAIN="$(get_variant_config "${ENVIRONMENT}" 2>/dev/null | jq -r '.domain // empty' || true)"
PROXY_DOMAIN="$(get_config_value 'proxyDomain' "${VMNAME}${DOMAIN:+.${DOMAIN}}")"
UI_DIRECT_URL="https://${IP}:9443"
if [[ -n "${PROXY_DOMAIN}" ]] && getent hosts "${PROXY_DOMAIN}" >/dev/null 2>&1; then
UI_URL="https://${PROXY_DOMAIN}"
else
[[ -n "${PROXY_DOMAIN}" ]] && info " ${PROXY_DOMAIN} does not resolve yet — using the direct URL"
UI_URL="${UI_DIRECT_URL}"
fi
# ── 1. Podman engine + the rootful API socket ────────────────────────
info " Installing podman + tooling (apt)..."
vm "sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq" || die "apt-get update failed"
vm "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y podman curl jq ca-certificates" \
|| die "failed to install podman"
# Rootful socket: this is the Docker-compatible API Portainer talks to.
# podman-restart.service is what makes --restart=always survive a reboot.
vm "sudo systemctl enable --now podman.socket" || die "failed to enable podman.socket"
vm "sudo systemctl enable --now podman-restart.service 2>/dev/null || true"
# ── 2. Portainer container ───────────────────────────────────────────
info " Pulling ${IMAGE}..."
vm "sudo podman pull -q ${IMAGE}" >/dev/null || die "failed to pull ${IMAGE}"
IMAGE_ID="$(vm "sudo podman image inspect --format '{{.Id}}' ${IMAGE}" | tr -d '\r')"
[[ -n "${IMAGE_ID}" ]] || die "could not read the image id for ${IMAGE}"
RUNNING_ID="$(vm "sudo podman container inspect --format '{{.Image}}' ${CONTAINER} 2>/dev/null" | tr -d '\r' || true)"
if [[ "${RUNNING_ID}" != "${IMAGE_ID}" ]]; then
# Recreate only when the image actually changed. The named volume carries all
# state, so removing the container loses nothing.
info " (Re)creating the ${CONTAINER} container..."
vm "sudo podman volume create portainer_data >/dev/null 2>&1 || true"
vm "sudo podman rm -f ${CONTAINER} >/dev/null 2>&1 || true"
vm "sudo podman run -d --name ${CONTAINER} --restart=always --privileged \
-p 8000:8000 -p 9443:9443 \
-v /run/podman/podman.sock:/var/run/docker.sock \
-v portainer_data:/data ${IMAGE}" \
|| die "failed to start ${CONTAINER}"
vm "echo '${IMAGE_ID}' | sudo tee ${MARKER} >/dev/null" || warn "could not write version marker"
else
info " ${CONTAINER} already running the current image — leaving it alone"
fi
# ── 3. Wait for the API ──────────────────────────────────────────────
info " Waiting for Portainer to answer on :9443..."
UP=0
for _ in $(seq 1 24); do
code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)"
[[ "${code}" == "200" ]] && { UP=1; break; }
sleep 5
done
[[ "${UP}" -eq 1 ]] || die "Portainer did not answer on :9443"
# ── 4. Bootstrap the local admin ─────────────────────────────────────
# The password is kept on the VM for break-glass access — OIDC is the everyday
# path, this is the account that survives an Authentik outage.
#
# The guard is "can we log in?", NOT "does the password file exist": a failed
# init leaves a password file behind, and keying off the file makes every later
# run skip the bootstrap forever (seen on the first lab1 install).
if ! vm "sudo test -s ${ADMIN_SECRET}" 2>/dev/null; then
vm "sudo install -d -m 0700 \$(dirname ${ADMIN_SECRET})"
vm "openssl rand -base64 24 | sudo tee ${ADMIN_SECRET} >/dev/null && sudo chmod 0600 ${ADMIN_SECRET}"
fi
ADMIN_PW="$(vm "sudo cat ${ADMIN_SECRET}" | tr -d '\r')"
portainer_jwt() {
vm "curl -sk -X POST https://localhost:9443/api/auth \
-H 'Content-Type: application/json' \
-d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null | jq -r '.jwt // empty'
}
wait_for_api() {
local code
for _ in $(seq 1 24); do
code="$(vm "curl -fsk -o /dev/null -w '%{http_code}' https://localhost:9443/api/status 2>/dev/null" || echo 000)"
[[ "${code}" == "200" ]] && return 0
sleep 5
done
return 1
}
# Echoes the HTTP status of an admin-init attempt. The setup token is minted
# afresh at every container start, so re-read it each time.
init_admin() {
local tok hdr=""
tok="$(vm "sudo podman logs ${CONTAINER} 2>&1 | grep setup_token | grep -oE '[0-9a-f]{64}' | tail -1" | tr -d '\r')"
[[ -n "${tok}" ]] && hdr="-H 'X-Setup-Token: ${tok}'"
vm "curl -sk -o /dev/null -w '%{http_code}' -X POST https://localhost:9443/api/users/admin/init \
-H 'Content-Type: application/json' ${hdr} \
-d '{\"Username\":\"admin\",\"Password\":\"${ADMIN_PW}\"}'" 2>/dev/null || echo 000
}
JWT="$(portainer_jwt)"
if [[ -z "${JWT}" ]]; then
info " Bootstrapping the break-glass admin account..."
init_code="$(init_admin)"
if [[ "${init_code}" == "303" ]]; then
# Portainer closes the admin-init window a few minutes after start
# (Redirect-Reason: AdminInitTimeout). Without this the module can never
# be bootstrapped again after any transient failure — restarting resets
# the timer and mints a new setup token.
info " admin-init window had closed — restarting ${CONTAINER} to reopen it"
vm "sudo podman restart ${CONTAINER} >/dev/null" || warn " restart failed"
wait_for_api && init_code="$(init_admin)"
fi
case "${init_code}" in
200|204) info " admin created (password in ${ADMIN_SECRET} on the VM)" ;;
409) warn " an admin exists but ${ADMIN_SECRET} does not match it — reset it by hand" ;;
*) warn " admin init returned HTTP ${init_code}; configure the admin by hand at ${UI_URL}" ;;
esac
JWT="$(portainer_jwt)"
else
info " break-glass admin already provisioned"
fi
# ── 5. Point Portainer at TAPPaaS identity (OIDC) ────────────────────
# identity:identity wrote these three values; if they are missing the module is
# still usable with the local admin, so warn rather than fail.
if [[ -z "${JWT:-}" ]]; then
warn " no admin session — skipping OIDC configuration (see the admin init warning above)"
elif ! vm "sudo test -s ${SECRETS_ENV}" 2>/dev/null; then
warn " ${SECRETS_ENV} missing — is identity:identity in dependsOn?"
else
info " Configuring OIDC login against Authentik..."
CLIENT_ID="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_ID\"'" | tr -d '\r')"
CLIENT_SECRET="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_CLIENT_SECRET\"'" | tr -d '\r')"
DISCOVERY="$(vm "sudo sh -c '. ${SECRETS_ENV}; printf %s \"\$OIDC_DISCOVERY_URI\"'" | tr -d '\r')"
if [[ -n "${CLIENT_ID}" && -n "${CLIENT_SECRET}" && -n "${DISCOVERY}" ]]; then
# Portainer wants the three endpoints separately; the discovery document has them.
WELL_KNOWN="$(vm "curl -fsk ${DISCOVERY}" || true)"
AUTH_URI="$(jq -r '.authorization_endpoint // empty' <<<"${WELL_KNOWN}")"
TOKEN_URI="$(jq -r '.token_endpoint // empty' <<<"${WELL_KNOWN}")"
USER_URI="$(jq -r '.userinfo_endpoint // empty' <<<"${WELL_KNOWN}")"
if [[ -n "${AUTH_URI}" && -n "${TOKEN_URI}" && -n "${USER_URI}" ]]; then
if [[ -n "${JWT}" ]]; then
# AuthenticationMethod 3 = OAuth. OAuthAutoCreateUsers lets a
# TAPPaaS identity log in without an admin pre-creating it;
# Authentik's group binding is the actual access gate.
SETTINGS="$(jq -n \
--arg cid "${CLIENT_ID}" --arg csec "${CLIENT_SECRET}" \
--arg auth "${AUTH_URI}" --arg tok "${TOKEN_URI}" --arg usr "${USER_URI}" \
--arg redir "https://${PROXY_DOMAIN}/" \
'{AuthenticationMethod: 3,
OAuthSettings: {ClientID: $cid, ClientSecret: $csec,
AuthorizationURI: $auth, AccessTokenURI: $tok,
ResourceURI: $usr, RedirectURI: $redir,
UserIdentifier: "preferred_username",
Scopes: "openid profile email",
OAuthAutoCreateUsers: true, SSO: true}}')"
set_code="$(vm "curl -sk -o /dev/null -w '%{http_code}' -X PUT https://localhost:9443/api/settings \
-H 'Authorization: Bearer ${JWT}' -H 'Content-Type: application/json' \
-d '$(printf '%s' "${SETTINGS}" | tr -d '\n')'" || echo 000)"
if [[ "${set_code}" == "200" ]]; then
info " ${GN}✓${CL} OIDC login configured"
else
warn " settings update returned HTTP ${set_code} — configure OAuth by hand at ${UI_URL}"
fi
else
warn " could not authenticate as the local admin — skipping OIDC configuration"
fi
else
warn " discovery document at ${DISCOVERY} lacked the endpoints — skipping OIDC configuration"
fi
else
warn " ${SECRETS_ENV} did not carry all three OIDC values — skipping OIDC configuration"
fi
fi
echo ""
info "${GN}✓ Portainer CE installed${CL}"
echo ""
info "${BOLD}═══ Next steps ═══${CL}"
info " ${BOLD}Console:${CL} ${UI_URL} (direct: ${UI_DIRECT_URL})"
info " Sign in with your TAPPaaS identity. Break-glass admin password:"
info " ssh tappaas@${IP} 'sudo cat ${ADMIN_SECRET}'"
info " Add another host in the zone: run the Portainer agent there and add it"
info " under Environments — see INSTALL.md."

View file

@ -11,15 +11,6 @@
"category": "containers",
"status": "incomplete"
},
{
"moduleName": "portainer",
"repo": "community",
"moduleJson": "src/containers/portainer/portainer.json",
"vmid": 813,
"stack": "infrastructure",
"category": "containers",
"status": "incomplete"
},
{
"moduleName": "komodo",
"repo": "community",