Actions
30 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 | |
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.exec.open | Open a shell in a container | medium | 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 | |
containers.reconcile | Re-apply every stack | low | No dry run |
containers.backup.sources | List what a backup of this account should also hold (read) | low | No dry run |
containers.backup.stage | Copy a stack's volumes into the home for the backup | medium | No dry run |
containers.backup.unstage | Remove the staged copies of the volumes | low | No dry run |
containers.backup.restore | Put backed-up volumes into a new stack | high |
Permissions and limits
Permissions
containers.stack.viewView stacks, logs and statscontainers.stack.operateStart, stop and restart stackscontainers.stack.execOpen a shell inside a stack's containerscontainers.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 e0cdf300. 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.
A shell inside a container is opened from the stack's Services tab (containers.exec.open, permission
containers.stack.exec, a Team member needs that grant on the stack) and live logs follow with search (see
"Shell in a container and live logs" below).
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.deletefirst asksaccounts.deleting(synchronous,Host.EmitSync);containers.on_account_deletingstops and removes every stack of the account while its Linux user and slice still exist. A stack that fails to go stops the delete and names the reason;accounts.deletedthen only cleans registries (and sweeps stacks of accounts deleted by an older panel).
Shell in a container and live logs (w11-47)
containers.exec.open {id, service, cols, rows}: the caller must own the stack (or holdcontainers.stack.execon exactly that stack); the service must be running. containers then asksterminal.container.open(internal; refuses any caller but this module) for a PTY and returns a single-use ticket for the normal terminal stream. The session is always recorded, counted interminal.sessions, idle-closed like every terminal and ends with the panel session.- The agent builds the command:
docker exec --interactive --tty --env TERM=... <id> /bin/sh -c '<fixed>'. The container id is found by the account's own labels (rc.account,rc.stack, compose project and service), exactly one running container, so a stack name of another account finds nothing. No flag comes from the caller (no--privileged,--user,--volume,--workdir). The shell is the container's own configured user inside the container's namespaces and cgroup (under the account's slice, the stack's CPU / memory / pids limits), with the capabilities the stack was rendered with; with userns-remap container root is an unprivileged host uid. It is never a shell on the server. - Logs:
containers.logs.getfollows by cursor (since= the previousnext) and acceptsqto keep only matching lines. Each call is bounded (2000 lines, 1 MiB, 8 KiB per line); control sequences, other control characters and bidi overrides are removed in the agent, and the screen renders lines as text. It is a bounded poll (2.5 s), not a socket: a streamed socket would add a second long-lived gateway; the bound is what protects the node. - Stack names beginning
app-are reserved for the apps module (stack.deployandcatalog.installrefuse them).
Address at deploy time
Stack deploy and catalog install accept address and https. A supplied address is proxied to the primary published
endpoint. Clear Address to keep the stack private.