Skip to content

Fedora Atomic Desktop (Silverblue / Kinoite / Cosmic Atomic)

Fedora Atomic Desktop (Silverblue / Kinoite / Cosmic Atomic)

Section titled “Fedora Atomic Desktop (Silverblue / Kinoite / Cosmic Atomic)”

Atomic Desktop variants use an immutable filesystem. The root filesystem is read-only: you cannot install packages or drop binaries into /usr at runtime. This changes the installation approach.

  1. /usr is read-only. You cannot curl a binary into /usr/local/bin/ on a running system.
  2. dnf is not available at runtime. Package installation happens through rpm-ostree or at image build time[@docsfedoraprojectorg2026technical]
  3. /etc and /var are writable. K0s config (/etc/k0s/) and data (/var/lib/k0s/) work normally.
  4. System changes persist through image builds (Containerfile) or rpm-ostree overlays.

If you build your own Atomic image (using bootc, BlueBuild, or a plain Containerfile), add k0s at build time. This is the cleanest approach.

In your Containerfile (see e.g.[1] ) 1

# Download k0s
RUN K0S_VERSION=$(curl -sSf https://docs.k0sproject.io/stable.txt) && \
curl -sSfL "https://github.com/k0sproject/k0s/releases/download/${K0S_VERSION}/k0s-${K0S_VERSION}-amd64" \
-o /usr/bin/k0s && \
chmod +x /usr/bin/k0s
# Open firewall ports (firewall-offline-cmd because firewalld is not running during build)
RUN firewall-offline-cmd --add-port=6443/tcp && \
firewall-offline-cmd --add-port=2380/tcp && \
firewall-offline-cmd --add-port=9443/tcp && \
firewall-offline-cmd --add-port=8132/tcp && \
firewall-offline-cmd --add-port=10250/tcp && \
firewall-offline-cmd --add-port=179/tcp && \
firewall-offline-cmd --zone=trusted --add-interface=kube-bridge && \
firewall-offline-cmd --add-masquerade
# Ship config and setup script
COPY config/k0s/k0s.yaml /usr/share/k0s/k0s.yaml
COPY scripts/first-boot/setup-k0s.sh /usr/libexec/setup-k0s.sh

Then use a first-boot systemd service to run k0s install and k0s start. K0s install creates systemd units and writes to /var/lib/k0s/, both of which require a running system.

Example first-boot script:

#!/bin/bash
set -ouex pipefail
mkdir -p /etc/k0s
# Read role from /etc/k0s/role, default to controller
ROLE="controller"
if [[ -f /etc/k0s/role ]]; then
ROLE=$(cat /etc/k0s/role)
fi
# Copy config from image to /etc (writable overlay)
cp /usr/share/k0s/k0s.yaml /etc/k0s/k0s.yaml
case "$ROLE" in
controller)
k0s install controller --enable-worker --no-taints -c /etc/k0s/k0s.yaml
;;
worker)
k0s install worker --token-file /etc/k0s/join-token
;;
esac
k0s start
touch /var/lib/k0s-first-boot-done

Wire it with a systemd oneshot unit that runs once and marks itself done via the sentinel file.

If you do not build a custom image, you can place the binary somewhere writable. /usr/local/bin is part of the ostree-managed filesystem on some variants, but /var is always writable:

Terminal window
sudo mkdir -p /var/usrlocal/bin
K0S_VERSION=$(curl -sSf https://docs.k0sproject.io/stable.txt)
curl -sSfL "https://github.com/k0sproject/k0s/releases/download/${K0S_VERSION}/k0s-${K0S_VERSION}-amd64" \
-o /var/usrlocal/bin/k0s
sudo chmod +x /var/usrlocal/bin/k0s

Add /var/usrlocal/bin to your PATH, or symlink. Then follow the standard Fedora steps for firewall, SELinux, and installation. Open firewall ports with firewall-cmd (the live firewalld works fine at runtime).

The same SELinux steps from the standard Fedora section apply. container-selinux is pre-installed on most Atomic images. If not, layer it:

Terminal window
rpm-ostree install container-selinux
systemctl reboot

Create the containerd SELinux config at /etc/k0s/containerd.d/selinux.toml as described above. /etc is writable, so this works at runtime.

  1. The config/k0s.yaml :

    apiVersion: k0s.k0sproject.io/v1beta1
    kind: ClusterConfig
    metadata:
    name: k0s
    spec:
    extensions:
    helm:
    repositories:
    - name: openebs-internal
    url: https://openebs.github.io/openebs
    - name: metallb
    url: https://metallb.github.io/metallb
    - name: traefik
    url: https://traefik.github.io/charts
    charts:
    # Storage — order 0 so PVCs work before anything else needs them
    - name: openebs
    chartname: openebs-internal/openebs
    version: "4.4.0"
    namespace: openebs
    order: 0
    values: |
    engines:
    local:
    lvm:
    enabled: false
    zfs:
    enabled: false
    replicated:
    mayastor:
    enabled: false
    localpv-provisioner:
    hostpathClass:
    isDefaultClass: true
    # LoadBalancer — order 1, must be ready before Traefik requests an IP
    - name: metallb
    chartname: metallb/metallb
    version: "0.14.9"
    namespace: metallb
    # Ingress — order 2, needs both storage (ACME persistence) and LB
    - name: traefik
    chartname: traefik/traefik
    version: "39.0.5"
    namespace: traefik
    order: 2
    values: |
    ports:
    web:
    port: 8000
    expose:
    default: true
    exposedPort: 80
    redirections:
    entryPoint:
    to: websecure
    scheme: https
    permanent: true
    websecure:
    port: 8443
    expose:
    default: true
    exposedPort: 443
    tls:
    enabled: true
    certResolver: letsencrypt
    certificatesResolvers:
    letsencrypt:
    acme:
    # email: "you@example.com" # optional: get expiry warnings
    storage: "/data/acme.json"
    httpChallenge:
    entryPoint: web
    persistence:
    enabled: true
    accessMode: ReadWriteOnce
    size: 128Mi
    path: /data
    service:
    type: LoadBalancer
    workerProfiles:
    - name: default
    values:
    evictionHard:
    nodefs.available: "10Gi"
    imagefs.available: "10Gi"
    memory.available: "256Mi"
    nodefs.inodesFree: "5%"
[1]
github.com, “GitHub - race2infinity/The-Documentation-Compendium: 📢 Various README templates & tips on writing high-quality documentation that people want to read.” Accessed: Mar. 29, 2026. [Online]. Available: https://github.com/race2infinity/The-Documentation-Compendium