Apps#
Services → Apps is the store. An app is a container declared in the router's
configuration tree — set 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:
| Field | Value | Used for |
|---|---|---|
network | apps | the name of the shared container network |
prefix | 10.99.0.0/24 | the address range apps are placed in |
categories | the six above | the filter chips |
The stat tiles#
| Tile | Value |
|---|---|
| Available | Entries in the catalogue, with the category count beneath |
| Installed | Containers declared in the config tree |
| Running | Of those, the ones podman reports Up; amber when fewer than are installed |
| Not answering | Running, 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:
| Badge | Means |
|---|---|
host network | The entry requires the router's own network namespace. Any port it binds is bound on the router. |
privileged | It runs privileged and can reach the router's kernel interfaces directly. |
web UI | The 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.
| Step | Button | What happens | Why it is separate |
|---|---|---|---|
| 1 | Directories | POST /api/apps/prepare creates each volume source on the router | The router refuses the commit: Volume "conf" source path … does not exist |
| 2 | Pull image | POST /api/apps/pull runs add container image <ref> on the router | The router refuses the commit: Image "…" does not exist locally |
| 3 | Stage install | POST /api/apps/plan renders the operations; they go into the Commit Bar | This 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:
mkdir -p /config/apps/adguard/work /config/apps/adguard/confVolume 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:
set container network apps prefix 10.99.0.0/24— only when the shared network does not already exist and this app is not on host networking.set container name <name> image <image>.- Either
allow-host-networks, ornetwork apps address <address>. - For each volume:
source,destination, andmode rowhen the catalogue marks it read-only. - For each environment variable with a value:
environment <NAME> value <value>. - Each declared
capability, thenmemorywhen 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/confThe 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#
| Section | What it holds |
|---|---|
| Where it runs | Container 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. |
| Configuration | One 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 on | Informational, from the catalogue: container port, protocol and description. |
| Persistent storage | Each volume's source and destination, with a note that sources live under /config so they persist across upgrades. |
| Before you commit | Warnings computed by the planner, then the entry's own notes. |
| What it changes once running | The hints this app will offer, and on which page. |
| The commands this will stage | The 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/featuresets 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
deleteoperations, 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#
- Apps — installed — the declared-versus-running view and the feature modules.
- Apps — images — what is on the disk, and removing it.
- One app — logs, update check, restart, stop, uninstall.
- Integration offers — what an installed app offers other pages.
- Companions — the gap the catalogue deliberately does not fill.
- IDS and WireGuard — the two feature modules, once installed.
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.