variant: flatcar version: 1.0.0 systemd: units: # Gitea Actions runner. Registers once on first start and keeps its # identity (the .runner file) in /var/lib/oswald_runner on the VM's own # root disk -- not ephemeral, so it does not self-delete after each job # (that caused stale offline entries whenever the container was # interrupted before finishing a job; see flatcar-todo.md). Reusing the # same registration also means the token never needs rotating. # # /var/lib/oswald_runner survives container restarts and VM reboots (it's # on disk, not in the container), but not a VM rebuild (taint): that # recreates the disk from the image, so the runner re-registers fresh # next boot. Old entries from before this change, or from any future # rebuild, still need manual cleanup in Gitea's runner admin page. # # GITEA_RUNNER_REGISTRATION_TOKEN is templated in from Infisical # (GITEA_RUNNER_TOKEN in secrets.tmpl) by main.tf, not stored here. It # still ends up in plaintext in the rendered Ignition snippet on the # node's storage -- see the caveat in flatcar-todo.md. - name: oswald_runner.service enabled: true contents: | [Unit] Description=Gitea Actions runner After=docker.service versions-fetch.service Wants=versions-fetch.service Requires=docker.service [Service] # Image tag comes from versions.env; see butane/versions-sync.yaml. EnvironmentFile=/etc/versions.env TimeoutStartSec=0 ExecStartPre=-/usr/bin/docker rm --force oswald_runner ExecStart=/usr/bin/docker run --name oswald_runner \ -e GITEA_INSTANCE_URL=https://git.atlashorizon.net \ -e GITEA_RUNNER_REGISTRATION_TOKEN=${gitea_runner_token} \ -e GITEA_RUNNER_NAME=oswald_runner \ -v /var/lib/oswald_runner:/data \ -v /var/run/docker.sock:/var/run/docker.sock \ $${GITEA_RUNNER_IMAGE} ExecStop=/usr/bin/docker stop oswald_runner Restart=always RestartSec=5s [Install] WantedBy=multi-user.target # n8n workflow automation, served publicly at n8n.atlashorizon.net. # # Prerequisites, all of which must exist before the VM is rebuilt: # - an "n8n" Postgres database and role on argo.lan.atlashorizon.net # - N8N_DB_PASSWORD and N8N_ENCRYPTION_KEY in Infisical (see # secrets.tmpl); generate the key with `openssl rand -hex 32` # - n8n.atlashorizon.net terminating TLS and proxying to this host's # :5678 (not managed here) # # Workflows, credentials and execution history all live in Postgres, so # a taint loses nothing. The encryption key is what makes that true: # without a fixed one n8n generates its own under /home/node/.n8n, and # the credentials in Postgres become undecryptable the moment the VM # disk goes. Never rotate it without re-entering every credential. - name: n8n.service enabled: true contents: | [Unit] Description=n8n workflow automation After=docker.service versions-fetch.service Wants=versions-fetch.service Requires=docker.service [Service] # Image tag comes from versions.env; see butane/versions-sync.yaml. EnvironmentFile=/etc/versions.env TimeoutStartSec=0 ExecStartPre=/usr/bin/docker pull $${N8N_IMAGE} ExecStartPre=-/usr/bin/docker rm --force n8n # /var/lib/n8n holds only what is left in /home/node/.n8n once the # database and key are external: installed community nodes and # n8n's own config file. Losing it to a taint means reinstalling # community nodes, nothing more. # # N8N_PROXY_HOPS=1: requests arrive through the reverse proxy, so # n8n takes the client IP from X-Forwarded-For (rate limiting and # audit logs would otherwise see only the proxy). ExecStart=/usr/bin/docker run --name n8n \ -e DB_TYPE=postgresdb \ -e DB_POSTGRESDB_HOST=argo.lan.atlashorizon.net \ -e DB_POSTGRESDB_PORT=5432 \ -e DB_POSTGRESDB_DATABASE=n8n \ -e DB_POSTGRESDB_USER=n8n \ -e DB_POSTGRESDB_PASSWORD=${n8n_db_password} \ -e N8N_ENCRYPTION_KEY=${n8n_encryption_key} \ -e N8N_HOST=n8n.atlashorizon.net \ -e N8N_PROTOCOL=https \ -e WEBHOOK_URL=https://n8n.atlashorizon.net/ \ -e N8N_PROXY_HOPS=1 \ -e N8N_DIAGNOSTICS_ENABLED=false \ -v /var/lib/n8n:/home/node/.n8n \ -p 5678:5678 \ $${N8N_IMAGE} ExecStop=/usr/bin/docker stop n8n Restart=always RestartSec=5s [Install] WantedBy=multi-user.target # CISA KEV RSS feed. Turns the CISA Known Exploited Vulnerabilities # catalog into an RSS 2.0 feed (https://github.com/xsaveopt/cisa-kev-rss). # # Feed: http://192.168.7.60:3000/rss # Health: http://192.168.7.60:3000/health # # Stateless: the container refreshes from CISA every ten minutes and keeps # nothing on disk. Moved here from its own VM (cisa-kev-rss, 192.168.7.64). - name: cisa-kev-rss.service enabled: true contents: | [Unit] Description=CISA KEV RSS feed After=docker.service versions-fetch.service Wants=versions-fetch.service Requires=docker.service [Service] # Image tag comes from versions.env; see butane/versions-sync.yaml. EnvironmentFile=/etc/versions.env TimeoutStartSec=0 ExecStartPre=/usr/bin/docker pull $${CISA_KEV_RSS_IMAGE} ExecStartPre=-/usr/bin/docker rm --force cisa-kev-rss ExecStart=/usr/bin/docker run --rm --name cisa-kev-rss \ -p 3000:3000 \ $${CISA_KEV_RSS_IMAGE} ExecStop=/usr/bin/docker stop cisa-kev-rss Restart=always RestartSec=5s [Install] WantedBy=multi-user.target # Domain Watchdog: RDAP-based domain expiry/change monitoring # (https://github.com/maelgangloff/domain-watchdog), served at # domainwatchdog.atlashorizon.net. Three containers on a private docker # network: the web app on :8083, a messenger worker, and Valkey as the # job queue and lock store. # # Prerequisites, all of which must exist before the VM is rebuilt: # - a "domainwatchdog" Postgres database and role on # argo.lan.atlashorizon.net; the schema is migrated on every start # - an OIDC client in Authelia with redirect URI # https://domainwatchdog.atlashorizon.net/login/oauth/check, scopes # openid/email/profile, and token_endpoint_auth_method # client_secret_post (the app sends the secret in the POST body) # - DOMAIN_WATCHDOG_DB_PASSWORD, DOMAIN_WATCHDOG_APP_SECRET, # DOMAIN_WATCHDOG_OIDC_CLIENT_ID and DOMAIN_WATCHDOG_OIDC_CLIENT_SECRET # in Infisical (see secrets.tmpl). Every Butane file is rendered on # every plan, so until they exist no plan succeeds. Generate the app # secret with `openssl rand -hex 32`; keep the DB password URL-safe # and free of "%" (it is spliced into DATABASE_URL, and systemd reads # "%" as a specifier) # - domainwatchdog.atlashorizon.net terminating TLS and proxying to this # host's :8083 with X-Forwarded-Proto/Host (not managed here) # # Users, watchlists and domain history all live in Postgres, so a taint # loses nothing that matters. What does not survive it: # - queued RDAP jobs in Valkey; the scheduler re-queues them # - the JWT signing keys in /var/lib/domain-watchdog/jwt, which the # entrypoint regenerates when missing, so everyone logs in again. # They are on disk rather than in the container only so that a # plain restart does not do the same. # # Login is Authelia-only: registration is off, and the first OIDC login # for an email creates that user, so Authelia's access rules for the # client decide who gets in. - name: domain-watchdog-valkey.service enabled: true contents: | [Unit] Description=Domain Watchdog Valkey After=docker.service versions-fetch.service Wants=versions-fetch.service Requires=docker.service [Service] # Image tag comes from versions.env; see butane/versions-sync.yaml. EnvironmentFile=/etc/versions.env TimeoutStartSec=0 ExecStartPre=/usr/bin/docker pull $${DOMAIN_WATCHDOG_VALKEY_IMAGE} ExecStartPre=-/usr/bin/docker rm --force domain-watchdog-valkey # Every Domain Watchdog unit tries to create the network, so start # order does not matter; "-" because all but the first will fail. ExecStartPre=-/usr/bin/docker network create domain-watchdog # No volume and no port: the queue is disposable and only the other # two containers reach it, by name, over the domain-watchdog network. ExecStart=/usr/bin/docker run --rm --name domain-watchdog-valkey \ --network domain-watchdog \ --network-alias valkey \ $${DOMAIN_WATCHDOG_VALKEY_IMAGE} ExecStop=/usr/bin/docker stop domain-watchdog-valkey Restart=always RestartSec=5s [Install] WantedBy=multi-user.target - name: domain-watchdog.service enabled: true contents: | [Unit] Description=Domain Watchdog web app After=docker.service domain-watchdog-valkey.service versions-fetch.service Requires=docker.service Wants=domain-watchdog-valkey.service versions-fetch.service [Service] # Image tag comes from versions.env; see butane/versions-sync.yaml. EnvironmentFile=/etc/versions.env TimeoutStartSec=0 # Secrets are unit environment rather than lines in app.env, which # is world-readable; docker run forwards them by bare name below. Environment="APP_SECRET=${domain_watchdog_app_secret}" Environment="DATABASE_URL=postgresql://domainwatchdog:${domain_watchdog_db_password}@argo.lan.atlashorizon.net:5432/domainwatchdog?serverVersion=16&charset=utf8" Environment="OAUTH_CLIENT_ID=${domain_watchdog_oidc_client_id}" Environment="OAUTH_CLIENT_SECRET=${domain_watchdog_oidc_client_secret}" ExecStartPre=/usr/bin/docker pull $${DOMAIN_WATCHDOG_IMAGE} ExecStartPre=-/usr/bin/docker rm --force domain-watchdog ExecStartPre=-/usr/bin/docker network create domain-watchdog # The entrypoint runs DB migrations and generates the JWT keypair # into /app/config/jwt if it is missing, then starts FrankenPHP on # :80 (plain HTTP: SERVER_NAME=:80 in app.env, TLS is the proxy's). ExecStart=/usr/bin/docker run --rm --name domain-watchdog \ --network domain-watchdog \ --env-file /etc/domain-watchdog/app.env \ -e APP_SECRET \ -e DATABASE_URL \ -e OAUTH_CLIENT_ID \ -e OAUTH_CLIENT_SECRET \ -v /var/lib/domain-watchdog/jwt:/app/config/jwt \ -v /etc/domain-watchdog/content:/app/public/content:ro \ -p 8083:80 \ $${DOMAIN_WATCHDOG_IMAGE} ExecStop=/usr/bin/docker stop domain-watchdog Restart=always RestartSec=5s [Install] WantedBy=multi-user.target # Runs the queued RDAP lookups and notifications. Same image, so the # entrypoint also runs migrations here; ordering after the web app keeps # the two from racing on a fresh database. It exits every hour # (--time-limit, as upstream recommends against PHP memory creep) and # Restart=always brings it straight back. - name: domain-watchdog-worker.service enabled: true contents: | [Unit] Description=Domain Watchdog worker After=docker.service domain-watchdog-valkey.service domain-watchdog.service versions-fetch.service Requires=docker.service Wants=domain-watchdog-valkey.service versions-fetch.service [Service] # Image tag comes from versions.env; see butane/versions-sync.yaml. EnvironmentFile=/etc/versions.env TimeoutStartSec=0 Environment="APP_SECRET=${domain_watchdog_app_secret}" Environment="DATABASE_URL=postgresql://domainwatchdog:${domain_watchdog_db_password}@argo.lan.atlashorizon.net:5432/domainwatchdog?serverVersion=16&charset=utf8" ExecStartPre=/usr/bin/docker pull $${DOMAIN_WATCHDOG_IMAGE} ExecStartPre=-/usr/bin/docker rm --force domain-watchdog-worker ExecStartPre=-/usr/bin/docker network create domain-watchdog # --no-healthcheck: the image's HEALTHCHECK probes FrankenPHP's # metrics on :2019, which only the web app runs, so the worker would # always show unhealthy (upstream's compose disables it the same way). ExecStart=/usr/bin/docker run --rm --name domain-watchdog-worker \ --no-healthcheck \ --network domain-watchdog \ --env-file /etc/domain-watchdog/app.env \ -e APP_SECRET \ -e DATABASE_URL \ -e MESSENGER_CONSUMER_NAME=worker \ $${DOMAIN_WATCHDOG_IMAGE} \ php /app/bin/console messenger:consume --all --time-limit=3600 -vv ExecStop=/usr/bin/docker stop domain-watchdog-worker Restart=always RestartSec=5s [Install] WantedBy=multi-user.target - name: dozzle-agent.service enabled: true contents: | [Unit] Description=Dozzle Agent After=docker.service versions-fetch.service Wants=versions-fetch.service Requires=docker.service [Service] # Image tag comes from versions.env; see butane/versions-sync.yaml. EnvironmentFile=/etc/versions.env TimeoutStartSec=0 ExecStartPre=-/usr/bin/docker rm --force dozzle-agent ExecStart=/usr/bin/docker run --name dozzle-agent \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 7007:7007 \ $${DOZZLE_IMAGE} agent ExecStop=/usr/bin/docker stop dozzle-agent Restart=always RestartSec=5s [Install] WantedBy=multi-user.target storage: directories: - path: /var/lib/oswald_runner mode: 0755 # The n8n image runs as the "node" user (uid 1000), which needs to own # its data directory. - path: /var/lib/n8n mode: 0700 user: id: 1000 group: id: 1000 # Domain Watchdog's JWT keypair; the container runs as root. - path: /var/lib/domain-watchdog/jwt mode: 0700 files: # Non-secret Domain Watchdog settings, shared by the web app and worker # units (docker --env-file format: no quoting, values taken literally). - path: /etc/domain-watchdog/app.env mode: 0644 contents: inline: | APP_ENV=prod SERVER_NAME=:80 HTTP_SECURE_COOKIE=true SYMFONY_TRUSTED_PROXIES=private_ranges SYMFONY_TRUSTED_HEADERS=x-forwarded-for,x-forwarded-host,x-forwarded-proto,x-forwarded-port LOCK_DSN=redis://valkey:6379 MESSENGER_ASYNC_TRANSPORT_DSN=redis://valkey:6379/messages MESSENGER_RDAP_LOW_TRANSPORT_DSN=redis://valkey:6379/messages-rdap-low MESSENGER_RDAP_HIGH_TRANSPORT_DSN=redis://valkey:6379/messages-rdap-high REGISTRATION_ENABLED=false REGISTRATION_VERIFY_EMAIL=false OAUTH_AUTHORIZATION_URL=https://auth.atlashorizon.net/api/oidc/authorization OAUTH_TOKEN_URL=https://auth.atlashorizon.net/api/oidc/token OAUTH_USERINFO_URL=https://auth.atlashorizon.net/api/oidc/userinfo OAUTH_SCOPE=openid email profile SSO_AUTO_REDIRECT=true MAILER_DSN=null://null # Markdown pages the frontend fetches from /content/; a missing one # renders as "Please create the /public/content/ file." Mounted # read-only into the web app. ads.md is shown on the login page and is # left empty on purpose (an empty file renders as nothing). - path: /etc/domain-watchdog/content/home.md mode: 0644 contents: inline: | # Domain Watchdog Tracks our domains through RDAP and alerts on expiry, transfers and other registry changes. Add domains under **Tracking > Watchlists**, and attach a notification connector to a watchlist to be alerted. - path: /etc/domain-watchdog/content/faq.md mode: 0644 contents: inline: | # FAQ **Who can log in?** Anyone allowed through Authelia for this app. Your account is created on first login. **How often are domains checked?** The worker refreshes watched domains from RDAP on a schedule; domains close to expiry are checked more often. - path: /etc/domain-watchdog/content/tos.md mode: 0644 contents: inline: | # Terms of use Private instance for Atlas Horizon use only. - path: /etc/domain-watchdog/content/privacy.md mode: 0644 contents: inline: | # Privacy Stores your email address (from Authelia) and your watchlists. Nothing is shared with third parties; RDAP lookups go directly to the registries. - path: /etc/domain-watchdog/content/ads.md mode: 0644 contents: inline: ""