Corium’s published image is a base. Deriving your own is how you add the things a corium: block was never going to cover — a monitoring agent, your organisation’s CA bundle, a driver, a backup client. It is three lines of Containerfile for the simple cases.

It is also the supported way to do it. Installing an agent on a running node with dnf does not work, because /usr is read-only; installing it into /var with a runcmd works exactly until the next upgrade, when nothing puts it back. An agent that is part of the image is an agent that survives.


1. The one rule that decides everything

Corium’s filesystem contract, from concepts, is what makes a derived image either sound or quietly broken:

Who owns itWhat that means for you
/usrthe image, read-onlyPut your things here. This is what upgrades replace, so this is what follows a node forward
/etcthe machine, merged on upgradeShips defaults here only when there is nowhere else. An operator’s edit wins over yours, for ever
/varthe machine, seeded at install and never updated by an imageWrite nothing here at build time

That last row is the mistake that costs the most, because it does not fail. A file you COPY into /var reaches every machine installed from your image and no machine upgraded into it. Your test node, freshly installed, works perfectly. The fleet you upgrade never sees it, and nothing says so.

State directories are declared in tmpfiles.d instead, and systemd creates them on every boot. That is a rule Corium follows for its own state and it is not decoration.


2. A worked example: adding an agent

Say you want node_exporter on every node.

FROM ghcr.io/corium-os/corium:0.2.0

ARG VERSION=1.10.0
ARG SHA256=a1b2c3...   # the checksum you looked up, pinned here

# The binary goes in /usr, which is what an upgrade carries forward.
#
# Pinned by checksum and not by "latest": this is a build that will be repeated
# months from now, and a download that silently changed underneath it is a node
# fleet running something nobody chose.
RUN curl --fail --silent --show-error --location --retry 3 \
        --output /tmp/node_exporter.tar.gz \
        "https://github.com/prometheus/node_exporter/releases/download/v${VERSION}/node_exporter-${VERSION}.linux-amd64.tar.gz" \
    && echo "${SHA256}  /tmp/node_exporter.tar.gz" | sha256sum --check \
    && tar -xzf /tmp/node_exporter.tar.gz -C /tmp \
    && install -m 0755 "/tmp/node_exporter-${VERSION}.linux-amd64/node_exporter" \
        /usr/bin/node_exporter \
    && rm -rf /tmp/node_exporter*

# Units belong in /usr/lib/systemd/system. Never /etc/systemd/system: that is
# the machine's, and an operator who disables your unit there should stay
# disabled across upgrades.
COPY node-exporter.service /usr/lib/systemd/system/node-exporter.service

# Enabled at build time, so a node boots with it running rather than waiting
# for someone to turn it on.
RUN systemctl enable node-exporter.service

# The same check CI runs on Corium's own image. It catches the /var mistake
# above, among others, and it is cheap.
RUN bootc container lint --fatal-warnings

With node-exporter.service beside it:

[Unit]
Description=Prometheus node exporter
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/node_exporter
Restart=always
DynamicUser=yes
ProtectSystem=strict
ProtectHome=yes
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target

If your agent needs a directory of its own, declare it rather than creating it:

# /usr/lib/tmpfiles.d/node-exporter.conf
d /var/lib/node-exporter 0750 root root -

3. Build it, and sign it

podman build --tag ghcr.io/you/corium:0.2.0-1 --file Containerfile .
podman push ghcr.io/you/corium:0.2.0-1

Then sign it. This is not optional hardening — an unsigned image cannot be upgraded onto a node.

Corium ships a /etc/containers/policy.json that requires a signature for ghcr.io/corium-os/corium, with a default of insecureAcceptAnything for everything else. Before staging an image, a node checks the policy scope that would apply to it and refuses any image the policy would accept without checking who built it. Your repository falls under the default, so it is refused:

the node's signing policy does not require a signature for this image

That is deliberate: it is what stops a typo rebasing a Kubernetes node onto somebody else’s image. The way through it is to sign yours and say so, which gives your image the same guarantee Corium gives its own:

cosign generate-key-pair
cosign sign --key cosign.key ghcr.io/you/corium:0.2.0-1

Then teach your image to require it, by adding your repository to the policy inside your own image:

"ghcr.io/you/corium": [
    {
        "type": "sigstoreSigned",
        "keyPath": "/usr/share/containers/your-cosign.pub",
        "signedIdentity": { "type": "matchRepository" }
    }
]

The node accepts your image because you said so, not because it claims to be Corium. That is the whole design of the signing policy, and it is why deriving an image does not mean giving up the guarantee.

Two things about the order of events:

A node enforces the policy it is currently running, not the one in the image it is moving to. So the entry above has to reach a node before the upgrade that needs it. In practice that means one upgrade to a derived image that adds only the policy and the key — signed, so the nodes accept it — and your agents from the next one onwards. Getting this backwards leaves nodes that refuse every image you build, and the way back is the console.

Installing is not upgrading. A machine installed from an ISO or a disk built from your image never runs that check, because there is no previous node to refuse anything. New machines are easy; it is the fleet you already have that needs the order above.


4. Put it on your nodes

A derived image is an image. Everything that moves a node to a new one works unchanged:

cctl upgrade node-a node-b --image ghcr.io/you/corium:0.2.0-1

One node at a time, stopping at the first that does not come back on the digest it was sent to. Rollback is the same cctl rollback as ever — the previous image is still on the disk, which is the other half of why this is safe to do to a cluster you care about.

For a new machine, build an installer ISO or a disk from your image the way Corium builds its own; see downloads.


5. The mistakes, in the order people make them

Writing to /var at build time. Covered above, and worth repeating because it is the only one that passes every test you are likely to run.

Putting a unit in /etc/systemd/system. It works, and then an upgrade three-way merges /etc and you are arguing with a machine about whose file it is. /usr/lib/systemd/system is the image’s.

Forgetting systemctl enable. The unit ships, the node boots, nothing runs, and it looks like the agent is broken rather than switched off.

Deriving from latest. Your build stops being reproducible the moment Corium publishes. Pin the version you tested — 0.2.0, not latest — and move it deliberately.

Skipping bootc container lint. It is the cheapest check available and it knows about this filesystem contract. Corium’s own CI would refuse an image that fails it.

An agent that fights the node. DynamicUser, ProtectSystem and friends cost nothing here and keep an agent from being the reason a Kubernetes node is compromised. Corium’s own daemons carry them, and say in comments which ones had to be dropped and why.


What this does not cover

Changing the Kubernetes version. The k0s binary lives in /usr and is pinned by build/k0s.lock; replacing it in a derived image is possible and puts the version skew of your cluster in your hands, which is a larger subject than this page. See upgrades.