Skip to content

Container Hosting (Docker extension)

The Docker extension brings per-website container hosting to the panel — built on rootless Podman, isolated and with no Docker daemon by default. It is installed as an opt-in extension via the marketplace; the panel core stays untouched until it is enabled.

Architecture in one sentence

A container is a new website app-type (alongside PHP, Node.js, Python, Static), bound to exactly one website. It runs rootless as that website's Linux user, is reachable only through the website's own nginx domain proxy (port bound internally to 127.0.0.1), and is managed from a dedicated Containers sidebar page.

Installation & permission

  1. Enable the extension (marketplace) — installs the runtime only on the selected servers: Podman + the rootless helpers (uidmap, fuse-overlayfs, passt) and the Grype CVE scanner. This is what makes "no Docker daemon by default" true.
  2. Package permission — in the package the admin decides with Allow container hosting (perm_docker) whether a customer may use containers, plus limits: Containers, RAM per container (MB), CPUs per container, Disk per container (GB) (0 = unlimited).

Running a container

  1. Create a website of type "Container" (under Websites). It automatically gets an internal port and an nginx proxy rule: domain → 127.0.0.1:<port>.
  2. On the Containers page, deploy an image. The dialog browses Docker Hub live — nothing to maintain: an empty search shows the most-starred official images, and typing searches all of Docker Hub. Picking a result fills the image reference (plus a best-effort internal port for common images) and offers a Version / tag dropdown (the image's real Docker Hub tags, newest first) so a specific version can be pinned instead of the implicit :latest. Custom image keeps a free reference (still subject to the registry allowlist). The controller proxies Docker Hub (avoids CORS + shares one rate-limit bucket) and caches results ~30 min. Podman pulls the image from docker.io and Grype scans it on deploy. The deploy runs in the background (the row shows Deploying …) — the first deploy on a server also installs the runtime + Grype DB and can take a few minutes. Then: start / stop / restart / remove, live status/health, and logs.

Security model (rootless, honest)

Every container runs with:

  • Rootless / user namespace per website (its own subuid/subgid range) — never host root.
  • --cap-drop=ALL plus a minimal allowlist (CHOWN, SETUID, SETGID, NET_BIND_SERVICE), --security-opt=no-new-privileges.
  • seccomp default-deny profile (Podman's default, additionally pinned explicitly).
  • Resource limits from the package (--memory, --cpus, --pids-limit).
  • Network: port published on 127.0.0.1 only (reachable only through the website's own domain proxy); host isolation via --no-map-gw (the container cannot reach host services through the gateway — SSRF / lateral-movement guard); the existing SMTP egress block (port 25) stops direct-to-MX spam.

Limits of the rootless model (no sugar-coating)

  • AppArmor is not applied to containers: rootless Podman cannot load an AppArmor profile (missing CAP_MAC_ADMIN). Kernel-level confinement is provided by seccomp + caps + user namespace.
  • Shared kernel: by default (standard isolation) rootless containers share the host kernel — a kernel privesc CVE affects all. Opt into gVisor (below) to interpose a user-space kernel and remove that shared-kernel exposure, at a performance cost.

Isolation level (gVisor)

The extension setting container_isolation chooses the runtime that new deploys use:

  • standard (default) — rootless Podman with crun. Containers share the host kernel; confinement is seccomp + caps + user namespace (as above).
  • gvisor — each container runs inside the gVisor sandbox (runsc), a user-space kernel that intercepts the container's syscalls so they never reach the host kernel directly. This closes the shared-kernel exposure — a kernel privesc CVE in the container no longer means host compromise — at the cost of some syscall/IO throughput.

gVisor runs fully rootless here (via a thin wrapper that runs runsc with --ignore-cgroups --network=host). The runtime is installed on demand: the first gvisor deploy on a server downloads and pins runsc; if it cannot be installed the deploy fails cleanly (gvisor_not_installed) rather than silently falling back to standard isolation. A container running under gVisor shows a gVisor badge on the Containers page. Switching the setting affects new deploys — redeploy an existing container to move it onto (or off) gVisor.

CVE pull-gate (Grype)

Before starting, every image is scanned with Grype; the result (counts per severity) rides along on every deploy response. The panel setting container_cve_gate controls the behaviour:

  • off (default) — scan and show only, never block.
  • critical — block the deploy when critical CVEs are present.
  • fixable-critical — block only when critical CVEs with an available fix are present.

Admins/resellers can override a block with force; customers cannot. When an image carries critical CVEs, the container_vulnerability_detected notification fires to admin and customer.

The Security Advisor shows a "Container CVEs" card that re-scans every deployed container image with Grype and lists the per-website severity counts. The card only appears when the Docker extension is enabled. A background worker also re-scans every 12h and fires container_vulnerability_detected when the total critical count rises — so CVEs that appear in a base image over time (without a redeploy) are still caught.

Site Doctor additionally checks each container site's live health: a stopped or unhealthy container is flagged with a one-click Restart, and a missing container links to the Containers page for a redeploy. The auto-worker raises site_issue_detected the first time a container problem appears.

Dependencies & later-added servers

Enabling the extension fans out to every active server and installs the runtime idempotently (Podman + rootless helpers + Grype + subordinate-id ranges). A server added after the extension was enabled does not get this fan-out — so the agent self-installs Podman on the first container deploy (and backfills subuid/subgid) instead of failing. The result: every server that ever hosts a container has the full runtime, whether it was present at enable-time or added later.

Persistent data volume, backup & restore

  • Data volume — set a volume path on deploy (e.g. /data) to give the container a persistent volume. It is a bind-mount of container-data/ under the website's home, mounted with :U (Podman chowns it to the container's user). Under the default rootless mapping a root-running container's writes are owned by the site user on the host. Without a volume path the container is fully ephemeral (data lost on recreate).
  • Full definition persisted — image, env, internal port and volume path are all stored on the website, so the container can be reconstructed from DB state alone.
  • Backup — the volume rides the website's normal file backup as plain files. The rootless image/container store (~/.local/share/containers) is excluded from backups (re-pullable image layers, not data).
  • Auto-redeploy on restore — after a file restore of a container site, the panel recreates the container from the persisted definition, re-attached to the restored volume. The nginx domain proxy already points at the fixed host port, so the site is reachable again automatically.
  • Cleanup — deleting the website (or the customer) cleanly stops+removes the container, disables the lingering session, and frees the subuid range — no orphaned containers.

Registry allowlist & image management

  • Curated registry allowlist — the extension setting container_registry_allowlist (comma-separated, empty = any) restricts which registries images may come from. A deploy from a registry not on the list is rejected (registry_not_allowed). Docker's implicit docker.io rule is applied (nginx → docker.io).
  • Image management — the container page lists the local images per website and offers "Clean images" to remove unused/dangling images, freeing disk that counts against the package quota.

Logs

The Logs action streams the container's stdout/stderr (the last lines, refreshable). Containers use the k8s-file log driver so podman logs works reliably for a rootless container — the host's default (journald) does not return logs for rootless containers.

A container created before this was fixed shows no logs

Container images deployed on an older agent used the journald driver and show an empty log view. Redeploy the container once (same image) and logs start flowing. New deploys use k8s-file automatically.

Not yet included (Phase 2)

  • Database-consistent volume snapshots (quiesce before backup).
  • Compose / multi-container apps per website.
  • Docker Hub pull-through cache to speed up repeated pulls.