Immutable bootc-based Fedora image for a single-node personal web stack: a public Hugo site plus tailnet-only WebDAV and PostgreSQL, with stateless configuration rendered from GCE instance metadata on every boot.
The image carries no site-specific values. The served domain, credentials, and Tailscale auth key all flow from GCE instance metadata at provision time, meaning the exact same image can be deployed anywhere. Caddy handles the public site on port 443, while PostgreSQL and WebDAV are strictly private, accessible only over your Tailscale tailnet.
Filesystem layout follows the conventions described in the Fedora bootc documentation.
- Stateless Configuration:
/etcis configured as transient viaprepare-root.conf. All runtime configurations are re-rendered from/usr/etctemplates and instance metadata on every boot. Persistent state lives exclusively under/var. - Base Image:
Containerfilebuilds uponquay.io/fedora/fedora-bootc:latest. The final stage copiestailscale,tailscaled,caddy,rclone, andenvsubstfrom their official images, and a dedicated stage builds PostgreSQL from source against the same base. No service is installed through dnf.setup.shremoves SSH and handles users, permissions, and service enablement. - Continuous Integration: A GitHub Actions workflow builds the image with Podman, pushes it to
ghcr.io, and rebuilds a GCP disk image to serve as a recovery seed. A Cloud Scheduler job dispatches the workflow daily so package updates ship even without commits. - Automatic Updates: The running VM updates itself directly from the container registry via the
bootc-fetch-apply-updatessystemd timer. The GCP image is only used for disaster recovery or initial VM creation.
Configuration is managed via dotenvx and pushed to GCE instance metadata during provisioning.
Install dotenvx, then copy the environment template:
cp .env.example .envFill in the values in .env:
ZONE,MACHINE_TYPE,DISK_SIZE,DISK_TYPE: GCP zone, instance type, and boot disk shape passed togcloud compute instances create.DOMAIN: Primary domain Caddy serves (fills{$DOMAIN}in the Caddyfile).ACME_EMAIL: Email for ACME CA registration.REDIR_LIST: Comma-separated addresses that permanently redirect tohttps://${DOMAIN}.MTA_STS_URL: URL whereprovision.shfetches the MTA-STS policy body.TS_AUTHKEY: Tailscale OAuth client secret (scoped toauth_keyswrite ontag:server).GH_WORKFLOW_TOKEN: Fine-grained GitHub PAT with Actions read/write on this repository only. Cloud Scheduler uses it to dispatch the daily build workflow.POSTGRES_PASSWORD,WEBDAV_USERNAME,WEBDAV_PASSWORD: Plaintext credentials.provision.shhashes these locally so only secure hashes reach instance metadata.
Once filled, encrypt the file:
dotenvx encryptNote: Never commit .env.keys. Do not commit .env until it has been encrypted.
Run the provisioning script using dotenvx:
dotenvx run -- ./provision.shThis script pushes configuration into instance metadata, creates the VM, and idempotently creates the allow-tailscale-wireguard firewall rule (UDP 41641) for direct WireGuard paths and the bootc-build Cloud Scheduler job that dispatches the daily image build. On boot, post-startup-root.sh writes the metadata values to /run/caddy for Caddy's native {$VAR} substitution and renders /etc/rclone/webdav.htpasswd.
After provisioning, verify that you can connect to PostgreSQL over the tailnet. Once verified, remove the single-use secrets from instance metadata:
gcloud compute instances remove-metadata bootc --keys=ts-authkey,postgres-experiments-scramThese secrets are only consumed on the very first boot. Removing them does not affect reboots, but do not remove them until the PostgreSQL connection check above confirms that the first boot succeeded. The remaining metadata values are re-read on every boot and must stay.
PostgreSQL and WebDAV are reachable exclusively over the tailnet.
On first boot, post-startup-tailscale.sh logs the node in. On every boot, it resets the tailscale serve config to match the script. Tailnet TCP 5432 forwards to local PostgreSQL, and tailnet HTTPS 8080 proxies to a loopback rclone WebDAV server. Access control is managed in your Tailscale admin console policy file: clients may reach tag:server on TCP 5432 and 8080 only.
PostgreSQL:
psql 'postgresql://experiments:<PASSWORD>@bootc.<tailnet>.ts.net:5432/experiments'WebDAV:
WebDAV is served over HTTPS under the node's MagicDNS name, which requires HTTPS certificates to be enabled for the tailnet. tailscaled terminates TLS, obtains the Let's Encrypt certificate on the first request, renews it automatically, and caches it under /var/lib/tailscale, so the certificate survives reboots and image updates. Configure rclone (type = webdav, vendor = rclone) using the credentials from .env, then access it via:
rclone ls dav: # url = https://bootc.<tailnet>.ts.net:8080To rebuild the instance from scratch:
- Delete the existing VM in GCP.
- Run
dotenvx run -- ./provision.sh. - Delete the old machine in the Tailscale admin console. Because a recreated VM registers as a new node, the dead node will hold onto the MagicDNS name until removed.
- Repeat the metadata cleanup step for single-use secrets.
Note: The OAuth client secret in .env does not expire, so no credentials need refreshing prior to reprovisioning.
The host minimizes attack surface via strict networking and systemd sandboxing rules:
- Network: No SSH server, client, or host keys in the image.
nftablesloads a static default-drop ruleset. Inbound traffic is restricted to web ports and WireGuard UDP. Outbound is limited to DNS, NTP, HTTP(S), and Tailscale ports. Only root can reach the GCE metadata service.tailscaledruns in userspace networking mode, so no tunnel interface exists and it never alters host rules. - Systemd Sandboxing: Every service, including oneshot boot units, runs under a strict systemd sandbox:
ProtectSystem=strict, private devices/tmp/IPC,MemoryDenyWriteExecute,SystemCallFilter=@system-service, restricted address families, and minimal capability bounding sets. - Least Privilege: Deviations from the sandbox are deliberate. Caddy retains
CAP_NET_BIND_SERVICE.tailscaledand the WebDAV server run completely unprivileged asDynamicUserservices with empty capability sets. - Secret Handling: Secrets are staged through a
credsgroup bridging root and postgres. The WebDAVhtpasswdis rendered root-only and delivered via systemdLoadCredential.
Containerfile: Multi-stage build definition.setup.sh: Build-time mutations (SSH removal, users, permissions, service enablement).provision.sh: Hashes passwords locally and creates the GCP instance.build-and-deploy.sh/.github/workflows/build-and-deploy.yml: CI pipeline for building and pushing the image..env.example: Template for environment variables.
post-startup-root.{sh,service}: Root-side boot setup. Renders runtime configuration files from metadata.fetch_metadata.sh: Helper the boot scripts use to read GCE instance metadata attributes.rclone-webdav.service: Sandboxed WebDAV server runningrclone serve.
caddy/:Caddyfile(static, with{$VAR}placeholders Caddy fills from/run/caddy/env), the sandboxedcaddy.servicefor the statically copied binary, andtmpfiles.conf.postgresql/: Server configs (postgresql.conf,pg_hba.conf),build.sh(the source build that runs in its own Containerfile stage), the sandboxedpostgresql.service, boot setup (post-startup-postgresql.{sh,service},bootstrap.sh,bootstrap.sql), andtmpfiles.conf.tailscale/:tailscaled.servicefor the statically copied binary and tailnet login and serve setup (post-startup-tailscale.{sh,service}).
prepare-root.conf,bootc.json,nftables.conf,fstab,journald.conf: Standalone configuration files.hash-pg-password/: Containerized SCRAM-SHA-256 hasher to generate secure PostgreSQL password hashes locally without a host installation.website/: Static site source files (Hugo).