Skip to content

uCore

Both homelab servers — monitoring and kontti — run uCore, an immutable, image-based Linux built on Fedora CoreOS. The OS is never modified in place: updates are applied as atomic image swaps and take effect after a reboot.

Why immutable Linux

With a traditional Linux server, configuration drift accumulates over time — packages installed manually, files edited by hand, state that exists nowhere except on the running machine. uCore flips this: the base OS is read-only, everything is provisioned declaratively, and the server can be rebuilt from scratch at any time using the same Ignition config and Ansible playbooks.

Initial provisioning

A new server is provisioned in two steps:

1. Generate the Ignition config from Butane

podman run --interactive --rm quay.io/coreos/butane:release \
  --pretty --strict < test.butane > test.ign

Butane is a human-friendly YAML format that compiles down to Ignition JSON. The config declares the initial user, SSH keys, hostname, keyboard layout, and the auto-rebase systemd services:

test.butane
variant: fcos
version: 1.4.0
passwd:
  users:
    - name: core
      ssh_authorized_keys:
        - ssh-ed25519 <public-key>
      password_hash: <yescrypt-hash>
storage:
  directories:
    - path: /etc/ucore-autorebase
      mode: 0754
  files:
    - path: /etc/hostname
      mode: 0644
      contents:
        inline: monitoring
    - path: /etc/vconsole.conf
      mode: 0644
      contents:
        inline: KEYMAP=fi
systemd:
  units:
    - name: ucore-unsigned-autorebase.service
      enabled: true
      contents: |
        [Unit]
        Description=uCore autorebase to unsigned OCI and reboot
        ConditionPathExists=!/etc/ucore-autorebase/unverified
        ConditionPathExists=!/etc/ucore-autorebase/signed
        After=network-online.target
        Wants=network-online.target
        [Service]
        Type=oneshot
        ExecStart=/usr/bin/rpm-ostree rebase --bypass-driver \
          ostree-unverified-registry:ghcr.io/ublue-os/ucore:stable
        ExecStart=/usr/bin/touch /etc/ucore-autorebase/unverified
        ExecStart=/usr/bin/systemctl disable ucore-unsigned-autorebase.service
        ExecStart=/usr/bin/systemctl reboot
        [Install]
        WantedBy=multi-user.target
    - name: ucore-signed-autorebase.service
      enabled: true
      contents: |
        [Unit]
        Description=uCore autorebase to signed OCI and reboot
        ConditionPathExists=/etc/ucore-autorebase/unverified
        ConditionPathExists=!/etc/ucore-autorebase/signed
        After=network-online.target
        Wants=network-online.target
        [Service]
        Type=oneshot
        ExecStart=/usr/bin/rpm-ostree rebase --bypass-driver \
          ostree-image-signed:docker://ghcr.io/ublue-os/ucore:stable
        ExecStart=/usr/bin/touch /etc/ucore-autorebase/signed
        ExecStart=/usr/bin/systemctl disable ucore-signed-autorebase.service
        ExecStart=/usr/bin/systemctl reboot
        [Install]
        WantedBy=multi-user.target

The two autorebase services handle the uCore bootstrap automatically. On first boot, ucore-unsigned-autorebase rebases to the unverified image and reboots. On the second boot, ucore-signed-autorebase takes over and rebases to the signed stable image. The ConditionPathExists guards ensure each service runs exactly once.

2. Boot from ISO and install

# Write ISO to USB
sudo dd if=fedora-coreos-*.iso of=/dev/sdX bs=4M status=progress && sync

# Serve Ignition file over HTTP and install
python3 -m http.server
sudo coreos-installer install /dev/nvme0n1 \
  --insecure-ignition \
  --ignition-url http://<YOUR_IP>:8000/test.ign

On first boot, two systemd services handle the uCore rebase automatically — first to the unsigned image, then to the signed stable image — rebooting between each step. After that the server is ready for Ansible.

Ansible

All ongoing configuration is managed with Ansible from the ucore-ansible repo. The playbook is run from the ansible/ directory:

# Both servers
ansible-playbook install.yml

# Single server
ansible-playbook install.yml --limit kontti

# Single role
ansible-playbook install.yml --limit kontti --tags media

# Only config files, no service restarts
ansible-playbook install.yml --tags config

Roles map to tags, making it easy to apply targeted changes without running the full playbook.

Podman and Quadlets

All services run as Podman containers. Instead of docker-compose, containers are defined as Quadlet files — systemd unit files that Podman generates into actual services at boot. Quadlet files live under /etc/containers/systemd/.

The two servers deliberately use different privilege models:

  • monitoring runs rootless Podman as the unprivileged core user. The observability stack has no need for host-level privileges, so keeping it rootless reduces the blast radius of a container compromise.
  • kontti runs Podman as root. Several of its services need direct device access (e.g. the AMD GPU for transcoding) and shared host paths, which is simpler to manage rootful.

This split is a conscious security boundary rather than an accident of configuration.

/etc/containers/systemd/
  plex.container
  immich-server.container
  immich-postgres.container
  ...

This means every container is a native systemd service: systemctl start plex, journalctl -u plex, restart policies, dependencies — all handled by systemd.

Automatic updates

podman-auto-update runs on a daily timer and pulls new image versions for any container with AutoUpdate=registry set. After a successful update, a script sends a Telegram notification with the list of updated containers.

Scheduled reboots

Since uCore applies OS updates as image swaps, a reboot is needed to activate them. A systemd timer triggers a scheduled reboot so updates are picked up automatically without manual intervention. The cadence is set per host to match each server's role:

Server Reboot schedule
monitoring Daily at 06:30
kontti Weekly, Saturday at 05:00

monitoring reboots daily to stay current, while kontti reboots only once a week to avoid interrupting long-running media tasks and active streams more often than necessary.

My experience

I chose an atomic OS for the servers because the same model had already proven itself on my laptop, which runs Aurora Linux — part of the same Universal Blue family as uCore. It also mirrors what I work with professionally: OpenShift uses the same pattern — an immutable base OS with everything running in containers — so the mental model carried straight over.

The genuinely new thing was Ignition; first-boot provisioning was a concept I hadn't worked with before, and getting the Butane config right took some iteration. Running Borgmatic in a container also turned out to be more awkward than running it natively, since it wants host paths and scheduling that are simpler to wire up outside a container. Once the server was up, though, deploying the applications with Ansible was smooth.

One thing to watch for: when you remove or change a service, it's worth checking that no old Quadlet units, containers, or images are left dangling — removing the Ansible task doesn't clean those up for you.

I haven't actually had to reinstall a server from scratch yet, so the "rebuild from nothing" capability is still theory for me — but it's the design I trust, and the Ignition-plus-Ansible path is there when I need it. Day to day, the servers have been genuinely appliance-like: as long as they have power, the OS and the applications keep themselves up to date.