Containers

Docker Compose stacks and one-click apps for each hosting account, isolated and resource-limited.

In preview containers version 0.1.0 Part of Containers and apps

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.

ActionWhat it doesRiskPreview
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.view View stacks, logs and stats
  • containers.stack.operate Start, stop and restart stacks
  • containers.stack.manage Deploy, change and remove stacks
  • containers.image.manage Pull and remove images
  • containers.registry.manage Manage private registry credentials
  • containers.catalog.view Browse the app catalog
  • containers.runtime.admin Install and administer the container runtime

Plan limits

  • containers.stacks Stacks
  • containers.cpu CPU of all containers
  • containers.memory_mb Memory 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/compose is 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_add outside CHOWN 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 with driver/driver_opts/external (a type: none, o: bind volume 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), labels rc.account/rc.stack, one project network. Ports are 127.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 with openat(O_NOFOLLOW): a symlink planted in the home cannot redirect root's mkdir/chown (lab-verified: containers/evil -> /etc is refused, nothing appears in /etc). New directories are owned by container root (the dockremap uid base) with the account's group, setgid. Compose files use create_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.get masks them.
  • Registry credentials: vault secret per credential; written to /var/lib/rc-containers/<account>/.docker/config.json (0600) and used as DOCKER_CONFIG for 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:, port from a per-node range 20000-29999 (stack_ports, unique per node). The web module's reverse-proxy site type:

  1. calls containers.endpoint.list {account_id} (permission containers.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};
  2. renders an nginx site proxy_pass http://<upstream> (WebSocket upgrade when websocket), TLS from ssl, and only switches the route when the stack is running (health_path = path to probe on the upstream);
  3. subscribes to containers.stack.deployed (payload {id, name, account, node_id, status, endpoints[]}) to re-render when a port changes and to containers.stack.removed to remove the site. It must treat an upstream as valid only if it is 127.0.0.1 and inside 20000-29999. Domain ownership stays in domains; containers never touches nginx. containers.domain.publish from 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 dockremap range): 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; backups is 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.delete stops the account slice before our event: a container whose PID 1 ignores SIGTERM made that take 90 s (> the 60 s agent call cap) until init: true was added; an accounts.deleting pre-event (or slice TimeoutStopSec) would make it robust for every module.