Template Module¶
Introduction¶
This is a template module that serves as a starting point for creating new TAPPaaS modules.
A TAPPaaS module typically runs in its own VM and provides a specific service or capability. The module name becomes the VM name, hostname, and DNS name.
Where will your module be maintained? Before you start, decide which repository your module will live in — the open-source TAPPaaS repo (contributed via a Pull Request), a community repository, or a private/downstream one — because that shapes your development workflow. See Git & repository topology.
Creating a New Module¶
The step-by-step path — copy the template, rename, configure, module-manager module add — is the quick start in DEVELOP.md. This page is the reference behind it: every file, field and convention in detail.
Pick a module name (typically the main software product or the capability delivered); it becomes the VM name, hostname and DNS name. Optionally record authorship in AUTHORS.md (edit the placeholder line, or delete the file — it is optional; see src/foundation/schemas/README.md for the contributor/author/maintainer role model).
Module documentation (ADR-013)¶
Every module carries Diataxis-split documentation (ADR-013 §4). Copy the skeletons and fill them in:
| File | Copy from | Audience | Required? |
|---|---|---|---|
README.md | README-template.md | end user — what / why / what-not | Mandatory (catalog lint) |
INSTALL.md | INSTALL.md | TAPPaaS admin — how to install / operate | Mandatory (catalog lint) |
DESIGN.md | DESIGN-template.md | module developers — how / why it is built | Expected where internals are non-trivial |
README.md and INSTALL.md are public web pages (synced to tappaas.org); DESIGN.md stays in-repo for contributors. The cross-module "how to build any module" walkthrough is DEVELOP.md — a different artifact from a module's own DESIGN.md.
Module Files¶
myModule.json¶
The JSON file defines all external parameters of the module: VM size, ID, name, VLAN membership, etc. The automated create, install, and update scripts of TAPPaaS use this file.
Modify it to set good defaults for your module. Installers can further customize through this file.
Example Configuration¶
{
"version": "1.0.0",
"description": "My awesome service module",
"vmid": 200,
"node": "tappaas1",
"cores": 2,
"memory": 4096,
"diskSize": "16G",
"storage": "tanka1",
"imageType": "clone",
"image": "9000",
"zone0": "srv",
"cloudInit": "true",
"dependsOn": ["cluster:vm", "templates:nixos", "backup:vm", "network:proxy", "network:rules"],
"provides": []
}
dependsOn is how the module composes with the rest of the platform — a VM, a template to clone, a backup job, a reverse-proxy entry, firewall rules. See Dependencies and services below for the full model.
Common Configurations¶
| Use Case | Key Settings |
|---|---|
| NixOS VM clone | imageType: "clone", image: "<vmid>", ostype: "l26", os: "nixos", cloudInit: "false" |
| Debian/Ubuntu cloud image | imageType: "clone", image: "<vmid>", ostype: "l26", os: "debian", cloudInit: "true" |
| Windows Server 2025 clone | imageType: "clone", image: "8081", ostype: "win11", os: "windows", cloudInit: "false" |
| ISO install (Linux) | imageType: "iso", image: "<filename>", imageLocation: "<url>", ostype: "l26" |
| ISO install (Windows) | imageType: "iso", image: "<filename>", ostype: "win11", cloudInit: "false" |
| Disk image (e.g. OPNsense) | imageType: "img", image: "<filename>", imageLocation: "<url>" |
| High Availability | Add HANode: "tappaas2", replicationSchedule: "*/15" |
| Multi-NIC | Add bridge1, zone1 fields |
Template dependencies and autoInstall¶
Some modules clone from a VM template rather than downloading an image directly (e.g. windows-server clones from tappaas-winserver, VMID 8081). If the template doesn't exist when you run module-manager module add, TAPPaaS checks the template JSON for the autoInstall flag:
autoInstall | What happens |
|---|---|
true | Template is built automatically before the clone proceeds — no operator input needed (e.g. tappaas-winserver uses autounattend.xml) |
false | Install stops with an actionable error — operator must build the template manually first (e.g. tappaas-nixos requires completing the graphical installer) |
This flag lives in the template's JSON (src/foundation/templates/tappaas-winserver.json), not in the consuming module's JSON. If you are building a new template module, set autoInstall: true only if the entire install can run without anyone at the console.
Choosing ostype and os¶
These two fields serve different purposes and are both needed for non-default OS types:
| Field | What it controls | Set it to |
|---|---|---|
ostype | QEMU hardware profile — clock source, ACPI behaviour, Hyper-V enlightenments, TPM availability | The guest OS family — see table below |
os | TAPPaaS bootstrap logic — which cloud-init snippet to attach, whether to deploy an OOBE answer ISO | The OS family string — see table below |
ostype quick-reference:
| Guest OS | ostype | Notes |
|---|---|---|
| NixOS, Debian, Ubuntu, any modern Linux | l26 | Default. The l is a lowercase letter L (Linux), not digit 1. Enables KVM paravirtual clock, UTC hardware clock. |
| Windows Server 2025, Windows 11 | win11 | Enables Windows ACPI, local-time hardware clock, Hyper-V enlightenments, TPM 2.0. Required for Server 2025. |
| Windows Server 2019/2016, Windows 10 | win10 | Same Windows treatment as win11, earlier ACPI profile. |
| Unknown / generic | other | Minimal optimisation. Use only when nothing else fits. |
Compatibility note: ostype does not prevent booting — a wrong value causes clock drift or incorrect power-management but the VM will still start. However, Windows Server 2025 specifically requires win11 for TPM 2.0, which Windows enforces during install.
Field Reference¶
For complete field definitions including all possible values, defaults, and validation rules, see:
install.sh¶
Installation script called with the module name as an argument when the module is installed. See README-install-sh.md for details
update.sh¶
Update script called periodically to keep the module updated.
- Called with module name as argument
- TAPPaaS calls this script on a periodic basis per the global
updateSchedule - Should handle incremental updates to the module
myModule.nix¶
NixOS configuration file for NixOS-based modules.
- Used by the default
install.shto rebuild the VM configuration - Remove this file for non-NixOS modules
Dependencies and services¶
TAPPaaS modules compose through services. A service is a capability one module offers and another consumes, named by a provider:service coordinate — e.g. cluster:vm, network:proxy, identity:identity. A module declares the coordinates it needs; TAPPaaS derives install order from those declarations rather than hardcoding it (ADR-003), then runs each provider's hooks to wire the dependent up.
Three <module>.json fields express this:
| Field | Meaning | If the provider is not installed |
|---|---|---|
dependsOn | hard requirement — the module cannot run without it | install is blocked |
integratesWith | soft — used if present | silently skipped; auto-wired if the provider is added later |
provides | the services this module offers to others (names only) | — |
Depending on other services — dependsOn¶
Almost every VM-backed module needs a VM created, a template to clone, a backup job, and usually a reverse-proxy entry and firewall rules — declared as coordinates. Nextcloud, for example, consumes six services and provides one:
{
"dependsOn": ["cluster:vm", "templates:nixos", "backup:vm", "network:proxy", "network:rules", "identity:identity"],
"provides": ["fileservice"]
}
module-manager module add nextcloud installs each provider first (if it is not already present), then calls that provider's install-service.sh with nextcloud as its argument, so each one provisions exactly what nextcloud declared — a VM, a backup enrolment, a Caddy entry, firewall rules, an SSO client. module modify runs the update-service.sh counterparts; deletion runs the chain in reverse.
Optional integrations — integratesWith¶
integratesWith is the soft counterpart to dependsOn: same provider:service shape, same install/update/delete-service.sh wiring — but a provider that is not installed is silently ignored instead of blocking, and when that provider is later installed it auto-wires the pre-existing integrators. Use it for a capability the module can run without — LiteLLM integratesWith: ["vllm-amd:inference"] uses a local inference backend if one exists and functions fine if none is deployed. A coordinate belongs in exactly one of the two lists.
Foundation services you can depend on¶
The foundation layer offers these provider:service coordinates — the building blocks nearly every module composes from. Each links to its contract: what it does for a dependent, and which <module>.json fields the dependent passes to it.
| Coordinate | What it does for your module |
|---|---|
cluster:vm | Creates and converges the module's Proxmox QEMU guest — the VM most modules run in. |
cluster:lxc | Creates and converges a Proxmox LXC container instead of a full VM. |
cluster:ha | Places the guest under Proxmox HA, with node-affinity and ZFS replication. |
backup:vm | Enrols the guest in the shared Proxmox Backup Server job. |
network:proxy | Publishes the module through Caddy — its public face and TLS termination. |
network:rules | Compiles the module's declared firewall surface into OPNsense rules. |
network:dns | Registers the module's DNS record on the resolver. |
network:nat | Adds destination-NAT (port-forward) rules on OPNsense. |
network:discovery | Relays mDNS / broadcast discovery across zone boundaries. |
identity:identity | Wires the module into single sign-on — OIDC client, redirect URIs, group access. |
templates:windows | Windows VM lifecycle (OOBE and beyond) for Windows-based modules. |
A few further coordinates have no standalone page yet — templates:nixos and templates:debian (the Linux template clones most modules use), identity:accessControl, and backup:remote / backup:external — documented on their parent module: templates, identity, backup. The full reference for every field a dependent can pass is module-fields.json.
Providing a service to others — provides¶
Beyond installing itself, a module can provide services that other modules depend on: list the service names in provides, and implement lifecycle hooks under services/<service-name>/, which TAPPaaS runs on the provider whenever a dependent module is installed or updated.
<module>/
├── <module>.json
├── install.sh # the module's own install
├── update.sh # the module's own update
└── services/
└── <service-name>/
├── install-service.sh # run when a dependent installs
├── update-service.sh # run when a dependent updates
└── fields.json # only if this service owns declared fields
install-service.sh is called with the dependent module's name; it reads that module's config from /home/tappaas/config/<dependent>.json, provisions what the dependent needs, and configures the service to support it. update-service.sh is the same for updates. (Deletion runs the dependency chain in reverse — see ADR-003.)
Writing a service script:
- Create
<module>/services/<service-name>/. - Add
install-service.sh(initial provisioning) andupdate-service.sh(ongoing updates). sourcethe shared helpers (common-install-routines.sh) forget_config_value,check_json, …- Read the dependent's fields with
get_config_value(respecting defaults). - Use
set -euo pipefailfor strict error handling.
If your service owns declared fields — fields.json¶
A field in module-fields.json whose usedBy names your <module>:<service> coordinate is yours, and you must declare what changing it costs after install: a fields.json alongside the scripts, or module-manager validate errors. That declaration is what lets an operator run module modify <m> --set yourField=… instead of hand-editing deployed config (ADR-020).
Most services own nothing — fourteen of TAPPaaS's twenty-five do registration and wiring, which is not field drift — and need no fields.json at all.
→ README-service-provider.md walks through it: choosing a change class, when apply: "reconcile" is the right answer (usually), and when you additionally need a report-service.sh.
Real examples to copy from: cluster/services/vm/install-service.sh (creates a VM for a dependent), cluster/services/ha/update-service.sh (HA / ZFS replication), and network/services/proxy/install-service.sh (Caddy reverse-proxy registration for a dependent's network:proxy). The full field reference for what a dependent can pass is module-fields.json.
Module Locations¶
| Type | Directory |
|---|---|
| Foundation modules | src/foundation/<name>/ |
| Application modules | src/apps/<name>/ |
| Community modules | src/<contributor>/<name>/ (in the TAPPaaS/Community repo) |
Debugging VMs¶
VM console screenshot (all OS types)¶
When you can't SSH into a VM — during setup, after a failed boot, or for a Windows OOBE check — take a screenshot via the Proxmox QEMU monitor. This works for NixOS, Windows, Debian, and any other VM type.
# Capture and base64-encode the screen (run from tappaas-cicd or any node with SSH access)
ssh root@<node>.mgmt.internal "qm screendump <VMID> > /tmp/screen.ppm && base64 /tmp/screen.ppm"
Copy the base64 output, then decode locally:
# macOS
echo "<paste base64 here>" | base64 -d | open -a Preview -f
# Linux (ImageMagick)
echo "<paste base64 here>" | base64 -d | display
Replace <node> with the Proxmox node name (e.g., tappaas1) and <VMID> with the VM ID. The VMID is in the module's JSON file (vmid field) and listed in src/module-catalog.json.
Proxmox QEMU monitor¶
Gives low-level access to the QEMU instance (disk I/O, CPU state, device info).
Naming Conventions¶
- Module name = VM name = hostname = DNS name
- Use lowercase with hyphens for multi-word names (e.g.,
home-assistant,open-webui) - Use descriptive names (e.g.,
nextcloud,vaultwarden,windows-server)
Deploying multiple instances¶
Any VM-backed module can be deployed multiple times using deploy-instances.sh:
count is the number of new instances to add on top of any already installed — not the total desired count. Instance 1 uses the original vmname and vmid from the JSON. Additional instances get a -<n> suffix and the next free VMID in the same hundreds block. A confirmation table is shown before anything is installed.
The module JSON does not need any changes to support this — the script handles all naming and VMID assignment automatically.