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 managed by root. 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/ (system-level), not under a user home directory, so Podman runs as root rather than rootless.

/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 nightly reboot at a configurable time, ensuring OS updates are picked up automatically without manual intervention.