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:
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:
monitoringruns rootless Podman as the unprivilegedcoreuser. The observability stack has no need for host-level privileges, so keeping it rootless reduces the blast radius of a container compromise.konttiruns 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.
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.