Design rationale¶
This repository provides a reproducible, provisioning-time approach to building a FCOS home server. It is intentionally opinionated and closely follows FCOS design principles rather than abstracting them away:
- Declarative, first-boot configuration via Ignition
- Rootless containers managed with systemd and Podman Quadlet
- Minimal post-installation interaction
As a consequence, this repository is not intended to be a general-purpose configuration management system or a dynamic service orchestration framework. Before adapting it to your own setup, this page explains how the generated configuration maps to Fedora CoreOS, Ignition, systemd and Podman.
Template architecture¶
Composability¶
A template is a Jinja2 file, templates/<key>.yaml.j2, that renders to a complete Butane configuration for one service. It receives exactly two things: the block of metaconfig.yaml under its key, and id, the position of that key in the file.
build_config.py renders one template per key and merges the results into a single Butane document with a recursive merge: dictionaries are merged key by key, lists (users, directories, files, systemd units...) are concatenated, and two templates that set the same scalar to different values are a build error. Templates therefore never reference one another; they only agree on conventions:
- Each rootless template creates its own user
u_<key>and groupg_<key>with uid and gid1000 + id, so the numbering is stable across builds and has no collisions as long as keys are not reordered. - Services reach each other exclusively through the host: a service publishes its
http_porton the host, and Caddy, Homepage or Prometheus address it ashost.containers.internal:<port>. Nothing depends on a shared network. - Rendering uses Jinja's strict mode: a parameter referenced by the template but missing from
metaconfig.yamlstops the build with the file and line number. Optional parameters are guarded in the template with{% if key.x is defined %}.
Removing a key from metaconfig.yaml removes the whole service from the generated configuration, and adding a service is adding a file under templates/.
Rootless by default¶
Every template that can run unprivileged does, as a distinct user:
- The user's
.config/containers/systemd/directory holds the Quadlet files,.config/systemd/user/default.target.wants/the symlink that enables the generated service, and.config/systemd/user/timers.target.wants/the one that enables Podman's update timer for that user. Ignition also creates.config/systemditself, because a parent directory created by Ignition would belong to root and break the user'ssystemctl --user. - A file in
/var/lib/systemd/linger/makes systemd start the user's session at boot, without any login. - Containers use user namespaces (
UserNS=keep-idorUserNS=auto): root inside a container is an unprivileged uid on the host, and bind mounts get the:ZSELinux label so that the container may use them.
The exceptions are adguardhome and wg_easy, which need kernel capabilities (NET_ADMIN, SYS_MODULE), a macvlan network, sysctls or kernel modules. Their Quadlet files live in /etc/containers/systemd/ and run as root, with their data under /var/<key>.
Building blocks¶
The templates are assembled from a handful of systemd and Quadlet units. Reading one of the existing templates with these four blocks in mind is the fastest way to understand them all: openwebui is the smallest single container, joplin the canonical pod, overleaf shows one-shot initialization, caddy socket activation.
Container¶
A <name>.container file is the Quadlet form of podman run. The templates always set:
[Container]
ContainerName=openwebui
Image=ghcr.io/open-webui/open-webui:main
AutoUpdate=registry
PublishPort=3009:8080
Volume=data.volume:/app/backend/data
[Service]
TimeoutStartSec=300
Restart=always
RestartSec=10
AutoUpdate=registry lets podman-auto-update.timer, which every template enables for its user, follow the image tag, which is why the examples pin a major or major.minor tag rather than latest (see Automatic updates). TimeoutStartSec=300 leaves time for the first image pull, and the restart policy turns transient failures (a drive not mounted yet, a database still starting) into retries. Configuration that the application reads from a file is written by Ignition into the user's home and bind-mounted read-only (:ro,Z); configuration that the application takes from the environment is passed with Environment= lines.
Volume¶
A <name>.volume file declares a Podman named volume:
Named volumes hold the state the application manages itself (databases, caches, generated keys): Podman creates them on first use and gives them the ownership the image expects, which a bind mount from the host would not. User data that must be reachable from outside the container, such as media libraries or photos, is a bind mount instead, from a directory under /var or from an ext4 drive.
Pod¶
Applications that ship as several containers (application, database, cache) are grouped in a <key>.pod:
The containers of a pod share a network namespace, so they reach each other on localhost or by container name, and only the pod publishes ports. Credentials between the containers of one pod are hard-coded in the template: they are not reachable from outside, and exposing them as parameters would only add decisions without adding security. Each container lists the ones it needs with Requires= and After= so the database starts first, and ServiceName= gives the whole pod the <key>.service name that the maintenance commands use.
Systemd service¶
Everything that is not a container is a plain user unit:
- One-shot initialization. Steps that upstream guides ask to run by hand (initialize a replica set, create a database, run a setup wizard) are a
Type=oneshotunit that waits for the application to answer, performs the step, then touches a stamp file in the user's home;ConditionPathExists=!<stamp>keeps it from running again. The main unit pulls it in withWants=, so a failed initialization is visible in the journal and retried on the next start, without taking the application down. - Socket activation. Caddy's
caddy.socketunit opens ports 80 and 443 (TCP and UDP) and passes the file descriptors to the container, so the service starts on the first connection and the container never needs the privilege to bind those ports. - Helpers. Jellyfin's
socat.serviceforwards a TCP port to the UNIX socket the application creates, and mount units generated by ext4_drives mount data drives on demand.
Scope¶
What is deliberately not here:
- No configuration management after boot. The configuration is applied once by Ignition. Changing a parameter means rebuilding and reprovisioning, or applying a single template by hand with
build_adhoc_script.pyas described in Maintenance. - No orchestration. There is no scheduler, no service discovery and no shared overlay network; services are static and addressed by port on the host.
- No secrets management.
metaconfig.yamlcontains the credentials in clear and ends up, through Ignition, readable by root on the server. Keep the file out of version control.