Wheelhouse docs

Apps#

Services → Apps is the store. An app is a container declared in the router's configuration treeset container name <x> … — which means an install is a diff you read before it happens, a commit you can roll back, and state that survives an image upgrade. There is no package manager and no separate lifecycle.

The page has three tabs — Catalogue, Installed and Images — and four stat tiles above them.

The catalogue#

The catalogue is agent/catalog.json, embedded in the agent binary at build time. It currently holds 38 entries: 36 containers and 2 feature modules, in six categories: DNS & filtering, VPN & remote access, Monitoring, Security, Network services, Management. Entries sort by the declared category order first and case-insensitively by name within a category, so dnsmasq does not land after Unbound and look like a bug — agent/catalog.go, loadCatalog.

GET /api/apps/catalog returns it verbatim. It also carries three values the install flow needs:

FieldValueUsed for
networkappsthe name of the shared container network
prefix10.99.0.0/24the address range apps are placed in
categoriesthe six abovethe filter chips

The stat tiles#

TileValue
AvailableEntries in the catalogue, with the category count beneath
InstalledContainers declared in the config tree
RunningOf those, the ones podman reports Up; amber when fewer than are installed
Not answeringRunning, but the health probe came back false; red when non-zero

A catalogue card#

Each card carries the name, the category, the blurb, the image reference, and badges for the things that should change your mind before installing:

BadgeMeans
host networkThe entry requires the router's own network namespace. Any port it binds is bound on the router.
privilegedIt runs privileged and can reach the router's kernel interfaces directly.
web UIThe entry declares a web interface, so an Open link is built once it has an address.

An entry that closes a documented platform gap says so on the card. Two do: dnsmasq ("Wildcard DNS · per-host DHCP options · PXE architecture matrix") and netboot.xyz ("PXE architecture matrix (with dnsmasq)").

The Docs ↗ link goes to the app's own upstream documentation. The Install button needs the operator role; a viewer sees it disabled with the reason.

Installing: three steps, one of which is configuration#

VyOS validates a container at commit time and refuses it if either prerequisite is missing. Both prerequisites are actions, not configuration, so they happen before the working diff exists. The install panel numbers them, and it hides step 1 for an app with no volumes.

StepButtonWhat happensWhy it is separate
1DirectoriesPOST /api/apps/prepare creates each volume source on the routerThe router refuses the commit: Volume "conf" source path … does not exist
2Pull imagePOST /api/apps/pull runs add container image <ref> on the routerThe router refuses the commit: Image "…" does not exist locally
3Stage installPOST /api/apps/plan renders the operations; they go into the Commit BarThis is the only part that is configuration

Nothing is running until you commit step 3.

Step 1 — directories#

The catalogue says which volume sources are directories and which are single files ("file": true). The agent creates directories with mkdir -p and files with a create-if-absent open, so an existing configuration file is never truncated. A source ending in .sock is skipped entirely — a socket is the daemon's to create. Guessing from the extension instead of trusting the catalogue previously created a directory named Caddyfile and left pihole's dnsmasq.d uncreated — agent/apps.go, handleAppPrepare.

This step only works when the agent runs on the router. Off-router it has no access to the router's filesystem, so the response carries on_router: false and the exact command to run over SSH, which the panel prints:

bash
mkdir -p /config/apps/adguard/work /config/apps/adguard/conf

Volume sources are validated before anything is created: they must be absolute paths and may not contain spaces or shell characters. The refusal names the field, because VyOS would only say Set failed at commit — validVolumeSource in agent/apps.go.

Step 2 — pull the image#

POST /api/apps/pull sends the image reference to the router's container-image endpoint. The reference is matched against an OCI grammar first: the router hands the name to podman image pull through a shell, so an unvalidated one is a command line on your firewall. The button reads Image present, with a tick, when the image is already local, and the pull log is shown in the panel afterwards.

Image pulls use the agent's long-running HTTP client — 15 minutes — because a 500 MB image on a domestic uplink is minutes, and the router keeps pulling after a short timeout hangs up, which would report failure for something that then succeeded — agent/vyos.go.

Step 3 — stage the install#

POST /api/apps/plan renders the operations without staging them, and the panel re-plans on every keystroke so what you see is always what will be staged. The plan is built by planInstall in agent/catalog.go, in this order:

  1. set container network apps prefix 10.99.0.0/24only when the shared network does not already exist and this app is not on host networking.
  2. set container name <name> image <image>.
  3. Either allow-host-networks, or network apps address <address>.
  4. For each volume: source, destination, and mode ro when the catalogue marks it read-only.
  5. For each environment variable with a value: environment <NAME> value <value>.
  6. Each declared capability, then memory when the entry sets one.

A worked example — AdGuard Home on a router with no container network yet:

set container network apps prefix 10.99.0.0/24
set container name adguard image docker.io/adguard/adguardhome:latest
set container name adguard network apps address 10.99.0.10
set container name adguard volume work source /config/apps/adguard/work
set container name adguard volume work destination /opt/adguardhome/work
set container name adguard volume conf source /config/apps/adguard/conf
set container name adguard volume conf destination /opt/adguardhome/conf

The commands are rendered by the same function the Commit Bar uses, so a value that needs quoting is quoted identically in the plan and in the diff.

The install panel, field by field#

SectionWhat it holds
Where it runsContainer name (defaults to the catalogue id) and address. The address defaults to the first free 10.99.0.x from .10 upward, computed against the addresses already in use. A checkbox offers host networking instead — for an entry that requires it, the checkbox is replaced by a warning.
ConfigurationOne field per declared environment variable. Required ones show an error until filled; secret ones are password inputs. Stage install stays disabled while a required variable is empty.
Ports it listens onInformational, from the catalogue: container port, protocol and description.
Persistent storageEach volume's source and destination, with a note that sources live under /config so they persist across upgrades.
Before you commitWarnings computed by the planner, then the entry's own notes.
What it changes once runningThe hints this app will offer, and on which page.
The commands this will stageThe rendered plan.

The planner's warnings are worth reading rather than skimming; they are generated, not copy:

  • "Runs on the host network: it shares the router's interfaces and any port it binds is exposed on the router itself."
  • "No address chosen — pick one inside 10.99.0.0/24 before committing."
  • "Environment variable <NAME> is required and has no value."
  • "Runs privileged. It can reach the router's kernel interfaces directly — install it only if you trust the image."

Feature modules#

Two catalogue entries are not containers at all. "kind": "builtin" marks a native feature whose Wheelhouse page is gated by installation — the OPNsense plugin shape, on a configuration-tree substrate. The two shipped are WireGuard and Suricata IDS.

A feature-module card behaves differently:

  • Install is instant and stages nothing. POST /api/apps/feature sets a flag in the agent's own state under /config/wheelhouse, and the page appears in the navigation. Nothing is pulled and nothing on the router changes.
  • Configuration outranks the flag. If the feature is configured on the router — by the CLI, by another operator, by anything — its page shows regardless of the flag. Wheelhouse never hides configuration that exists.
  • Uninstalling with live configuration hands you the delete. The response carries the delete operations, the UI stages them for review, and the page stays until they land. The toast says so: "Page stays until its config goes".

Both actions are audited, as feature-enable and feature-disable with the entry id.

What this page will not do#

  • No per-app configuration UI. An app's own settings live in the app. Wheelhouse links out rather than re-implementing another project's interface.
  • No container control socket. Start, stop and uninstall are configuration; restart is a two-commit bounce. See one app.
  • No registry credentials or mirror setting. An install behind a shared NAT can hit a registry's anonymous pull limit and get the registry's raw error back.

See also#


Checked against ui/src/pages/Apps.tsx, agent/catalog.go, agent/catalog.json, agent/apps.go, agent/vyos.go, agent/main.go, docs/apps.md.

Updated 2026-09-02 manual apps catalogue containers