Chapters

Overview

Container services run distribution-native root filesystems under OpenRC without Docker, Podman, containerd, or a Nix-store-based root filesystem. Deployment creates a ZFS dataset, downloads and verifies the selected base system, installs requested packages, configures namespaces and networking, applies a cgroup profile, and generates an OpenRC service.

Every container is opt-in. A declaration is inert until enable = true is set explicitly; a disabled container creates no dataset, root filesystem, network namespace, firewall rules, or OpenRC service.

The current implementation supports Alpine Linux 3.24.1 and Chimera Linux 20251220 as guest systems, both on x86_64 by default. Select one with os.

Minimal container

This is the smallest active declaration. It creates an Alpine root filesystem and runs the default idle command.

services.nftables.enable = true;
container.example = {
  enable = true;
  os = "alpine";
};

The generated OpenRC service is named container-example.

Disabled declaration

This configuration has no runtime or storage effect.

container.example = {
  enable = false;
  os = "alpine";
};

Profiles and networking

profile selects a complete baseline policy. It currently provides two things: a cgroup preset and a default network mode.

ProfileDefault networkmemory.maxpids.maxcpu.weight
defaultinternet512M256100
isolatedisolated256M12850

network controls only runtime connectivity. Its default value is null, which means that the network mode is inherited from profile. Setting network explicitly replaces the profile's network choice without changing memory, PID, or CPU limits.

The effective settings are resolved in this order:

  1. Select the cgroup and network defaults from profile.
  2. Replace the network mode when network is not null.
  3. Replace individual cgroup keys from cgroup.settings.
ConfigurationEffective networkEffective cgroup preset
profile = "default"internetdefault
profile = "isolated"isolatedisolated
profile = "default"; network = "isolated"isolateddefault
profile = "isolated"; network = "internet"internetisolated

Use profile when choosing the general resource and isolation policy. Use network only for an intentional exception to that policy.

Internet-enabled service

This example installs Nginx inside Alpine and runs it in the foreground. The container receives outbound internet access for package installation and runtime traffic. The host must explicitly set services.nftables.enable = true; the container module does not enable the firewall automatically.

services.nftables.enable = true;
container.web = {
  enable = true;
  os = "alpine";
  profile = "default";
  packages = [ "nginx" ];
  command = [ "/usr/sbin/nginx" "-g" "daemon off;" ];
};

The current networking implementation provides outbound NAT but does not declare stable host port forwarding. Do not treat the automatically assigned 10.240.* address as a public interface contract.

packages contains package names for the selected guest system, installed with its apk during deployment. command is the foreground process started by OpenRC inside the container; each argument is a separate list element.

Isolated container

This example has no Ethernet interface, route, DNS connectivity, or internet access at runtime.

container.worker = {
  enable = true;
  os = "alpine";
  profile = "isolated";
};

Custom cgroup limits

Explicit settings override the selected profile one key at a time.

container.processor = {
  enable = true;
  os = "alpine";
  profile = "isolated";
  cgroup.settings = {
    "memory.max" = "1G";
    "pids.max" = 64;
    "cpu.weight" = 200;
  };
};

The values are written to the OpenRC service's cgroup v2 configuration.

Network modes and overrides

The internet mode creates a private network namespace containing loopback and an eth0 veth interface. The host receives the other end of the veth pair, assigns the private subnet 10.240.<networkId>.0/30 with .1 on the host and .2 in the container, installs a default route in the container, enables IPv4 forwarding, and adds nftables forwarding and masquerade rules. This provides outbound IPv4 access but no stable public address or automatic host port forwarding.

networkId defaults to a value derived from the container name, so the subnet stays the same as long as the name does. Two names can lead to the same value. Evaluation then fails with a message that internet-enabled containers must use distinct networkId values, and one of them needs an explicit value between 1 and 254:

container.example.networkId = 42;

The isolated mode creates a private network namespace containing only loopback. It creates no veth interface, default route, forwarding rule, or NAT rule. A DNS configuration file may exist in the root filesystem, but it is unusable without a network route.

Runtime networking is separate from deployment-time package installation. packages is installed by the host during activation before the service enters its runtime network namespace, so an isolated container can still receive declared packages during deployment.

This example retains the default profile's cgroup limits while disabling runtime network access:

container.local-api = {
  enable = true;
  os = "alpine";
  profile = "default";
  network = "isolated";
};

This example retains the stricter isolated cgroup limits while enabling runtime internet access:

container.restricted-fetcher = {
  enable = true;
  os = "alpine";
  profile = "isolated";
  network = "internet";
};

Valid explicit values are "internet" and "isolated". Use network = null or omit the option to inherit the profile default.

ZFS storage

On a ZFS-root host, the dataset is inferred automatically as <root-pool>/containers/<name>. For example, container.web on rpool/root uses rpool/containers/web and mounts it at /var/lib/copland-containers/web.

container.web = {
  enable = true;
  os = "alpine";
  quota = "4G";
};

Use an explicit dataset when the host root filesystem is not on ZFS or when the container belongs in another pool.

container.archive = {
  enable = true;
  os = "alpine";
  profile = "isolated";
  dataset = "tank/containers/archive";
  mountpoint = "/srv/containers/archive";
  quota = "20G";
};

Dataset creation uses zfs create -p and therefore creates missing parent datasets. The root filesystem is stored under <mountpoint>/rootfs and is writable; no OverlayFS layer is used.

Set quota = null to create the dataset without a quota.

Combined settings example

container.archive-worker = {
  enable = true;
  os = "alpine";
  profile = "isolated";
  dataset = "rpool/containers/archive-worker";
  mountpoint = "/var/lib/copland-containers/archive-worker";
  quota = "8G";
  hostname = "archive-worker";
  cgroup.settings = {
    "memory.max" = "768M";
    "pids.max" = 96;
  };
};

Base image pinning

The release, mirror, architecture, and SHA-256 digest of each guest system are pinned in the module. Override all relevant values together when intentionally selecting another base image.

container.custom = {
  enable = true;
  os = "alpine";
  alpine = {
    version = "3.24.1";
    arch = "x86_64";
    mirror = "https://dl-cdn.alpinelinux.org/alpine";
    sha256 = "41f73e3cf5fa919b8aa5ca6b30dc48f0da2720776d7423e2a7748211456fe081";
  };
};

Chimera Linux publishes its root filesystems in dated directories. chimera.version selects that date, so a new Chimera release is chosen by changing the date and its digest together:

container.chimera = {
  enable = true;
  os = "chimera";
  chimera = {
    version = "20251220";
    arch = "x86_64";
    sha256 = "d497b90e75ab739c7d60bd082a3e0f68c2a63f7ee472005a8f9f2a42ba367be7";
  };
};

Deployment refuses to replace an existing dataset with a different base image, including a change of os. Use a new dataset or perform an explicit migration instead of silently replacing writable container state.

Deployment lifecycle

During activation, zfs.datasets imports the pool, creates the dataset when missing, mounts it, and applies its quota. The container activation then downloads the base image to a temporary file, verifies its SHA-256 digest, extracts it into the dataset, copies DNS configuration, and installs the requested packages with apk. The temporary file is removed afterwards, also when the setup fails.

At service start, OpenRC creates a dedicated network namespace, configures loopback and optionally a veth pair, then starts the workload under separate PID, mount, UTS, IPC, and network namespaces. /proc, /run, /tmp, and /dev are mounted inside the container namespace. At service stop, the network namespace and veth interface are removed.

Internet-enabled containers require the host to explicitly set services.nftables.enable = true. They enable IPv4 forwarding and extend the generated nftables configuration with forwarding and masquerade rules. Evaluation rejects internet-enabled containers when nftables is disabled; isolated containers do not require it.

Operations

Use the generated OpenRC service name for normal lifecycle operations.

rc-service container-example status
rc-service container-example start
rc-service container-example restart
rc-service container-example stop

List the enabled containers:

container list

Enter a running container with an interactive shell:

doas container enter example

Run a specific command in the running container:

doas container enter example -- /bin/sh -c 'cat /etc/alpine-release'

container enter joins the running container's PID, mount, UTS, IPC, and network namespaces before changing into its root filesystem. Root permission is required because namespace entry and chroot are privileged operations.

Changes made from the shell are persistent because the writable root filesystem lives in the container's ZFS dataset. Manual package or configuration changes are intentionally possible, but they are not represented by the declarative packages and module settings.

Service logs are written to /var/log/container-<name>/container-<name>.log.

A normal deployment applies storage and root filesystem changes before OpenRC reconciles the generated services.

just switch

Option reference

OptionDefaultMeaning
enablefalseExplicitly creates and runs the container.
os"alpine"Base operating system, "alpine" or "chimera".
profile"default"Common cgroup and networking preset.
networknullOptional "internet" or "isolated" override.
networkIdderived from the container nameThird octet of the internet subnet, 1 to 254.
datasetinferred from the root ZFS poolExplicit ZFS dataset name.
mountpoint/var/lib/copland-containers/<name>Dataset mountpoint.
quota"2G"Dataset quota or null.
hostnamecontainer attribute nameHostname inside the UTS namespace.
commandidle shell loopCommand and arguments executed inside the root filesystem.
packages[]Guest packages installed during deployment.
environment{}Environment passed to the container command.
cgroup.settingsselected profilePer-key cgroup v2 overrides.
alpine.version"3.24.1"Alpine minirootfs version.
alpine.arch"x86_64"Alpine architecture.
alpine.mirrorofficial Alpine mirrorBase download URL.
alpine.sha256pinned digestRequired minirootfs integrity digest.
chimera.version"20251220"Chimera ROOTFS build date.
chimera.arch"x86_64"Chimera architecture, "x86_64" or "aarch64".
chimera.mirrorofficial Chimera mirrorBase download URL.
chimera.sha256pinned digestRequired ROOTFS integrity digest.