---
marp: true
theme: gaia
class: invert
paginate: true
title: How to implement a TAPPaaS module
description: Live build of a Portainer module — OrangeMaker, 24 August
---
# Getting a module onto TAPPaaS
Live build of a **Portainer** container console
OrangeMaker · 24 August
---
## The plan
1. **What TAPPaaS is** — a few diagrams, ten minutes, no deeper
2. **What a module actually is** — a json contract and three scripts
3. **Build one live** — the `lab1` environment, then `portainer` into it
4. **Prove it works** — tests, then the web console
5. **You get a login** — your own identity on this system
You leave with: a mental model, a module you watched get built, and an account.
---
## The shape of a TAPPaaS site
```mermaid
flowchart LR
sat["satellite VPS
public IP · ingress
off-site backup"]
subgraph z0["mgmt · zone mgmt"]
cicd["tappaas-cicd"]
net["network"]
clu["cluster"]
bak["backup"]
idp["identity"]
end
subgraph z1["default · zone srv"]
nc["nextcloud"]
ha["home-assistant"]
end
subgraph z2["lab1 · zone lab1"]
pod["portainer — today"]
end
sat --> net
cicd --> nc
cicd --> pod
```
---
## The words we will use
| Word | Meaning |
| --- | --- |
| **Module** | The smallest deployable unit — its own VM, its own json contract. Foundation *and* apps are modules. |
| **Environment** | A tenant. `mgmt` and one named after the site always exist; add more freely. Today we add `lab1`. |
| **Zone** | A VLAN with a firewall policy. Modules land in their environment's zone. |
| **Mothership** | `tappaas-cicd` — where the managers run and where you type. |
| **Satellite** | Optional VPS, for sites with no usable public IP. |
Everything is a module. There is one model to learn, not eight.
---
## Developing modules: dev instance, private branch
```mermaid
flowchart RL
subgraph Development["Development repository"]
dev_main["main"]
end
subgraph Upstream["Upstream repository"]
up_main["main"]
up_stable["stable"]
up_main -->|merge| up_stable
end
subgraph InstanceDev["TAPPaaS instance · dev"]
localDev["clone of Upstream"]
localDev_dev["clone of Development"]
end
subgraph InstanceProd["TAPPaaS instance · prod"]
localProd["clone of Upstream"]
end
localProd -->|pull| up_stable
localDev -->|pull| up_main
localDev_dev -->|push| dev_main
dev_main -->|PR| up_main
```
---
## Our setup tonight
```mermaid
flowchart RL
subgraph t["TAPPaaS · codeberg"]
stable["stable"]
main["main"]
end
subgraph c["Community · codeberg"]
cm["main"]
end
subgraph f["forgejo.makerfloss.eu"]
fm["main"]
end
subgraph inst["Our TAPPaaS · tappaas-cicd"]
lt["clone of TAPPaaS"]
lc["clone of Community"]
lf["clone of MakerFLOSS"]
end
lt -->|pull| main
lc -->|pull| cm
lf -->|pull| fm
classDef tracked stroke:#ff8a80,stroke-width:3px
class main tracked
```
We track **`main`**, not `stable` — this is a lab, we want the new things.
---
## A module is a contract and three scripts
```text
portainer/
├── portainer.json # the contract — what it is, needs, provides
├── install.sh # put the software in the VM (once)
├── update.sh # keep it patched (on schedule)
├── test.sh # prove it still works (gates updates)
├── README.md # what it is
└── INSTALL.md # what automation cannot do for you
```
That is the whole surface. Everything else is the platform's job.
---
## The contract, for real
```json
{
"description": "Portainer CE — container console with TAPPaaS login",
"vmname": "portainer", "vmid": 813,
"dependsOn": ["cluster:vm", "templates:debian", "backup:vm",
"network:proxy", "identity:identity"],
"identity": { "oidcRedirectPaths": ["/"],
"secretsEnv": "/etc/secrets/portainer.env" },
"config": {
"cluster:vm": { "cores": 2, "memory": "2048", "diskSize": "32G",
"image": "debian-13-generic-amd64.qcow2" },
"network:proxy": { "proxyPort": 9443, "proxyUpstreamTls": true }
}
}
```
`dependsOn` is the whole trick: five services, declared — not scripted.
---
## `install.sh` — called once, at add
```bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/update.sh" "$@"
```
By the time it runs: `cluster:vm` built the VM, `templates:debian` apt-upgraded it,
`network:proxy` published the name — and `identity:identity` already created the
OIDC application and dropped its client credentials on the VM.
Nothing here is install-only, so **install *is* update**. Five lines is a perfectly
good `install.sh`.
---
## `update.sh` — where the work is
Runs on the **mothership**, not on the VM. It:
1. finds the node hosting the VM via `pvesh`, resolves its IP through the **guest agent**
2. `apt install podman`, then enables the **rootful** `podman.socket` — that socket
*is* the Docker-compatible API Portainer speaks
3. pulls `portainer-ce:lts` and runs it against the socket + a named volume
4. creates a **break-glass admin**, password into `/etc/secrets/portainer-admin`
5. reads `/etc/secrets/portainer.env`, fetches the OIDC **discovery document**,
and `PUT`s the three endpoints into `/api/settings` → login becomes OAuth
Re-runnable throughout: the container is recreated only when the image digest
actually changed, and an existing admin password is never regenerated.
---
## `test.sh` — the one that earns trust
| Check | How |
| --- | --- |
| VM reachable | IP resolves via the guest agent |
| container API | rootful `podman.socket` is active |
| app running | `podman container inspect` says `running` |
| API alive | `/api/status` returns a version |
| **identity intact** | `/api/settings/public` reports method `3` = OAuth |
That last one is the check worth stealing. It catches the failure nobody notices:
identity quietly falling back to local accounts, so the app still *works* while no
longer being the login you thought it was.
Run on demand, and again before every update is merged. If `test.sh` is honest,
unattended updates are safe — that is the entire deal.
---
## What you did not have to write
- A VM — `cluster:vm` built it from the Debian 13 cloud image
- OS prep — `templates:debian` did apt + guest agent
- A VLAN, an interface, DHCP, firewall rules — the zone came with the environment
- `https://portainer-lab1.lab1.makerfloss.eu`, valid cert — `network:proxy`
- An OIDC application, a group binding and client credentials — `identity:identity`
- Nightly backup to PBS, scheduled updates, health reporting
A few lines of json bought all of it.
---
## Demo 1 — an environment of our own
```bash
network-manager add lab1 --from-zone makerfloss # --check first for a dry run
network-manager show lab1 # vlan 299, 10.2.99.0/24, access-to
network-manager reconcile # dry-run: drift on all 4 planes
environment-manager add lab1 --display "MakerFLOSS lab" --owner makerfloss \
--zone lab1 --domain lab1.makerfloss.eu
environment-manager show lab1
```
`add` reconciles every plane itself. A tenant with its own VLAN, firewall
posture and DNS names — `lab1.makerfloss.eu` outside, `lab1.internal` inside.
---
## Demo 2 — install the module
```bash
site-manager repository add forgejo.makerfloss.eu/TAPPaaS/makerfloss --branch main
module-manager module add portainer --environment lab1
module-manager module list # what is deployed
module-manager module show portainer-lab1 # note the -lab1 suffix
```
Outside the default environment the module is **`portainer-lab1`** — `add` takes
the base name, everything after it takes the effective one.
Watch the order: dependencies resolve first, then the VM, then the network, then
`install.sh`. Install order is **computed** from every module's `dependsOn` — nobody
maintains a list.
---
## Demo 3 — prove it, then look at it
```bash
module-manager module test portainer-lab1
```
Then open **`https://portainer-lab1.lab1.makerfloss.eu`** and press **Sign in**.
- No login form. You are already you — Authentik, via OIDC.
- Not in `devops`? No console. The group binding **is** the access gate.
- Other machines in `lab1` join as Environments: one agent container on `:9001`.
Create a container. Start it. Read its logs. From a browser, as yourself.
---
## Where this module lives
```text
makerfloss/src/containers/portainer/
```
Two siblings next to it: `podman/` (one host, local login) and `komodo/` (GPL,
scripts still to write). Same job, three answers.
Three homes for a module, all first-class:
| Home | For |
| --- | --- |
| **TAPPaaS** repo, by pull request | modules the whole project should carry |
| **Community** repo | yours, shared, no gatekeeping |
| **Private** repo — `forgejo.makerfloss.eu/TAPPaaS/makerfloss` | ours, today's portainer |
Adding a repository is one `site-manager repository add`.
---
## Your turn — identity on this TAPPaaS
```bash
people-manager user add --email @example.org \
--roles user --groups devops
people-manager reconcile # preview
people-manager reconcile --apply # push to Authentik
authentik-manager user-recovery-link # one-time URL: set your own password
```
One login per person, roles via groups. Nobody types a password into a chat window.
---
## Take it further
- **Develop a module** — tappaas.org/generated/develop-a-module
- **Every json field** — tappaas.org/generated/schemas
- **Zones and the access model** — tappaas.org/generated/zones
- **Repository topology** — tappaas.org/generated/design/cicd-git
- **This deck** — slides.makerfloss.eu/tappaas/how-to/new-module
Pick an app you want on a sovereign box. Open an issue first — someone may already
be packaging it.