Actions
25 actions, callable from the panel, the command palette and the API as
POST /api/v1/a/<id>. Internal actions used between modules are not listed.
| Action | What it does | Risk | Preview |
|---|---|---|---|
containers.runtime.install | Install the container runtime | critical | No dry run |
containers.runtime.info | Container runtime status (read) | low | No dry run |
containers.stack.validate | Validate a Compose file (read) | low | No dry run |
containers.stack.deploy | Deploy a stack | medium | |
containers.stack.update | Update a stack | medium | |
containers.stack.start | Start a stack | low | No dry run |
containers.stack.stop | Stop a stack | low | No dry run |
containers.stack.restart | Restart a stack | low | No dry run |
containers.stack.remove | Remove a stack | high | |
containers.stack.list | List stacks (read) | low | No dry run |
containers.stack.get | Get a stack (read) | low | No dry run |
containers.logs.get | Stack logs (read) | low | No dry run |
containers.stats.get | Container stats (read) | low | No dry run |
containers.endpoint.list | Published endpoints (read) | low | No dry run |
containers.image.pull | Pull images | medium | No dry run |
containers.image.prune | Remove unused images | medium | No dry run |
containers.registry.add | Add a registry credential | medium | No dry run |
containers.registry.list | List registry credentials (read) | low | No dry run |
containers.registry.remove | Remove a registry credential | medium | No dry run |
containers.catalog.list | List catalog apps (read) | low | No dry run |
containers.catalog.get | Get a catalog app (read) | low | No dry run |
containers.catalog.install | Install a catalog app | medium | |
containers.catalog.upgrade | Upgrade a catalog app | high | |
containers.catalog.rollback | Roll back a catalog app | high | No dry run |
containers.reconcile | Re-apply every stack | low | No dry run |
Permissions and limits
Permissions
containers.stack.viewView stacks, logs and statscontainers.stack.operateStart, stop and restart stackscontainers.stack.manageDeploy, change and remove stackscontainers.image.managePull and remove imagescontainers.registry.manageManage private registry credentialscontainers.catalog.viewBrowse the app catalogcontainers.runtime.adminInstall and administer the container runtime
Plan limits
containers.stacksStackscontainers.cpuCPU of all containerscontainers.memory_mbMemory of all containers
Engineering notes
Generated from modules/containers/docs.md at build d90e9e2. These are the notes the engineers keep
next to the code: precise, technical, and honest about what is not done yet.
Docker Engine (from Docker's official apt repository) with userns-remap, the systemd cgroup driver and log
rotation. Every stack is a Compose project rc-<account>-<stack> whose containers live in the account's cgroup slice.
Exec into containers is NOT exposed in this module version.
Isolation model (what is enforced, and where)
- The user's YAML never reaches docker.
modules/containers/composeis an ALLOW-LIST validator: it parses with line numbers into a typed Spec and Renders a new canonical file. Unknown keys are refused ("option X is not supported"), dangerous ones with a specific reason. The control plane validates before it stores; the agent validates again (internal/agent/containers.go: ctrBuild) so a compromised control plane cannot push a privileged stack. - Refused (each tested with the offending line):
privileged,network_mode,pid/ipc/uts/userns_mode,devices,cap_addoutsideCHOWN DAC_OVERRIDE FOWNER FSETID KILL SETGID SETUID SETPCAP NET_BIND_SERVICE SYS_CHROOT,security_opt,sysctls,cgroup_parent,volumes_from,extra_hosts,env_file,build,container_name, custom/external networks, named volumes withdriver/driver_opts/external(atype: none, o: bindvolume would be a host mount), host-path mounts outside/home/<account>/(incl...,/home/<account>2, the home itself, the Docker socket), bind options (propagation), ports on any address but 127.0.0.1,$(…)/${X:-y}interpolation, undeclared${VAR}, merge-key/anchor tricks (the node tree is flattened before checking). - Rendered for every service:
cgroup_parent= the account slice (sliceUnit),cap_drop: ALL+ the small allowed set,no-new-privileges,init: true, CPU/memory (no swap)/pids limits (defaults 0.5 CPU, 256 MB, 512 pids), json-file log rotation (10 MB x 3), labelsrc.account/rc.stack, one project network. Ports are127.0.0.1:<allocated>. - Bind mounts (
./data=<home>/containers/<stack>/data, or an absolute path inside the home) are created by the agent component by component withopenat(O_NOFOLLOW): a symlink planted in the home cannot redirect root's mkdir/chown (lab-verified:containers/evil -> /etcis refused, nothing appears in /etc). New directories are owned by container root (the dockremap uid base) with the account's group, setgid. Compose files usecreate_host_path: false. - Compose + secrets live in
/var/lib/rc-containers/<account>/<stack>/(root, 0700): compose.yaml has only${VAR}references; values are in a root-only.env(single-quoted literals; quotes/backslashes/newlines refused). Secret variables are stored in the vault (stack/<id>/<KEY>), never in the module tables, logs or events;stack.getmasks them. - Registry credentials: vault secret per credential; written to
/var/lib/rc-containers/<account>/.docker/config.json(0600) and used asDOCKER_CONFIGfor that account's pulls only.
Actions
runtime.install/info (admin) · stack.validate / deploy / update / start / stop / restart / remove / list / get ·
logs.get (tail + cursor: pass next as since to follow) · stats.get · endpoint.list · image.pull / prune ·
registry.add / list / remove · catalog.list / get / install / upgrade / rollback · reconcile · internal
on_account_deleted (subscription to accounts.deleted: removes the stacks even though the Linux user is already gone).
deploy, update, remove, catalog.install, catalog.upgrade support dry-run (plan with a real compose diff and a
secret-masked .env diff). Limits containers.stacks, containers.cpu (millicores), containers.memory_mb are Reserved
on deploy (sum of service limits; update reserves the delta), released on remove/failure/account deletion.
Long operations are agent jobs
Agent calls are capped at 60 s by the transport and sdk.Host.Agent cannot raise it. Stack apply, image pull and
snapshot take/restore therefore return CtrJobStarted at once; the control plane polls containers.job.status (short
calls) in agentJob until done. One job per stack at a time (conflict otherwise). Jobs live in agent memory: after an
agent restart the poll reports an unknown job and the caller runs containers.reconcile.
Reverse-proxy contract with web
Containers publishes only to 127.0.0.1:stack_ports, unique per node).
The web module's reverse-proxy site type:
- calls
containers.endpoint.list {account_id}(permissioncontainers.stack.view; the user's own account, or any account for admin/reseller) and lets the user pick an endpoint:{stack, service, container_port, upstream: "127.0.0.1:20001", primary, websocket, health_path, status}; - renders an nginx site
proxy_pass http://<upstream>(WebSocket upgrade whenwebsocket), TLS fromssl, and only switches the route when the stack isrunning(health_path = path to probe on the upstream); - subscribes to
containers.stack.deployed(payload{id, name, account, node_id, status, endpoints[]}) to re-render when a port changes and tocontainers.stack.removedto remove the site. It must treat an upstream as valid only if it is127.0.0.1and inside 20000-29999. Domain ownership stays indomains; containers never touches nginx.containers.domain.publishfrom the spec is therefore not implemented here: it is the web module's site type.
One-click catalog
templates/<id>/{manifest.yaml, compose.yaml} + templates/index.json (sha256 of every file) + index.json.sig
(ed25519). catalog.Load refuses unsigned, wrongly signed, unknown-key, tampered, or missing/extra files with the reason
(tested). The binary embeds the catalog signed with the dev key (seed from a public constant, rc-dev-1, see
tools/sign); release builds sign with the release key (RC_CATALOG_KEY_FILE/RC_CATALOG_KEY_ID) and trust it.
Manifest: id, name, template version, app_version, category, description, icon, licence, adapted_from, typed
variables (string/password/url/domain/number/bool/email; default, generate: password:N|hex:N|base64:BYTES|uuid,
secret, pattern, required), requirements, web {service, port, websocket, health_path}, backup {volumes, hooks},
post_install {open_path, note, credentials}, upgrade_notes. The healthcheck is the compose healthcheck: a stack is
healthy only when every one passes. Install resolves variables (generating secrets), checks plan limits, deploys, and
returns the credentials ONCE. Starter set (10): WordPress, Ghost, n8n, Uptime Kuma, Gitea, Nextcloud, Plausible, Metabase,
SeaweedFS (S3), Valkey.
Upgrade: pre-upgrade snapshot (tar of the stack's named volumes, stack stopped meanwhile) -> apply the new template ->
wait for health; on failure (or unhealthy) it re-applies the previous revision AND restores the snapshot automatically.
Rollback (manual) is available for 7 days after an upgrade (revision N-1, optional data restore). Revisions keep the
compose and non-secret variables; secret values stay in the vault.
Verified in the lab (2026-10-09, Ubuntu 24.04 node, Docker 29.9.0, Compose 5.6.0, vfs storage)
See the handover for commands. Docker installed through the op; userns-remap + systemd cgroup driver active; Uptime Kuma
from the catalog healthy on 127.0.0.1:20000 (container process = host uid 100000, cgroup
rc.slice/rc-acct.slice/rc-acct-alice.slice/docker-<id>.scope, mem/cpu/pids limits, capdrop=ALL); every refusal via the API
with its line; symlink-escape bind refused; secrets only in .env/vault; limits (stacks=3, cpu) refuse the extra
deploy; env update; reconcile recreates a killed container; stop/start/restart; logs (ANSI stripped) and stats; upgrade
1.0.0->1.0.1 with snapshot, manual rollback with data restore, automatic rollback after a failing upgrade; remove frees
containers/volumes/networks; account deletion removes its stacks via the event.
Known limits / follow-ups
- userns-remap is per daemon, not per account (one
dockremaprange): container root is an unprivileged host uid but it is the same uid for every account; AC-containers-06 (files owned by the account user) is only approximated (account GROUP + setgid). Per-account uid ranges need rootless Podman per account (security's preferred runtime) - next step. - Container egress to host services on the bridge gateway is not filtered (firewall belongs to
security); icc is off on the default bridge only. A published-port allocation does not check that the port is free on the host. - No Git deploy, jobs, scale, exec, platform stacks/host network, CVE scan, admin kill/GC, custom catalog sources, per-pack
entitlements, backups integration (hooks are declared and stored;
backupsis not built), domain publish (web), UI. - Snapshots are tarballs on the node (not yet registered with
backups); the 7-day window is checked, not enforced by GC. accounts.deletestops the account slice before our event: a container whose PID 1 ignores SIGTERM made that take 90 s (> the 60 s agent call cap) untilinit: truewas added; anaccounts.deletingpre-event (or slice TimeoutStopSec) would make it robust for every module.