Skip to content

Architecture

Overview

                    ┌─────────────────────────────┐
                    │   Cloudflare (tagai.uk)     │
                    │   Access (Zero Trust)       │
                    │   + per-device Tunnels      │
                    └──────────────┬───────────────┘
                                   │
                 ┌─────────────────┼─────────────────┐
                 │                 │                 │
        <id>.tagai.uk    <id>-nodered.tagai.uk   <id>-ssh.tagai.uk
        (dashboard,          (Node-RED editor,      (SSH, admins
         same target)          admins only)           only)
                 │                 │                 │
                 └─────────────────┼─────────────────┘
                                   │
                        ┌──────────▼──────────┐
                        │   Raspberry Pi       │
                        │  - cloudflared (svc) │
                        │  - Node-RED (svc)    │
                        └──────────┬───────────┘
                                   │ mTLS
                        ┌──────────▼───────────┐
                        │  EMQX MQTT broker    │
                        │  mqtts.tagai.xyz:8883│
                        └───────────────────────┘

cloudflared and Node-RED both run as native systemd services — there is no Docker anywhere in the deployed fleet.

Source of truth: config/devices.yml

Every device that should exist is declared once in config/devices.yml, grouped by environment:

environments:
  production:  { prefix: "rpi-prod",       devices: { <12-hex-serial>: {ip_address, location} } }
  staging:     { prefix: "rpi-staging",    devices: {...} }
  development: { prefix: "rpi-dev",        devices: {...} }
lite_environments:
  production:  { prefix: "rpi-lite-prod",  devices: {...} }
  staging:     { prefix: "rpi-lite-staging", devices: {...} }
  development: { prefix: "rpi-lite-dev",   devices: {...} }

Device ID = {prefix}-{serial}, e.g. rpi-prod-1883c404a12f. "Lite" devices run a different Node-RED project and have extra bootstrap steps but use the same tunnel/Access/mTLS mechanics as standard devices.

terraform/main.tf reads this file (yamldecode) and creates one device module instance per entry. Known inconsistency: the production environment's device ID is hardcoded as "rpi-prod-${serial}" in terraform/main.tf rather than using environments.production.prefix like every other environment does — currently harmless since the prefix value is also "rpi-prod", but it means renaming that prefix in devices.yml alone would silently not change production device IDs. Tracked in decisions/optimization-proposal.md.

Ansible has its own, manually-maintained inventory (ansible/inventory/production/hosts.yml and hosts-lite.yml) that must be kept in sync with devices.yml by hand (scripts/generate-ansible-inventory.sh helps, but doesn't run automatically). This duplication is a known rough edge — see the optimization proposal.

Per-device networking

terraform/modules/device/main.tf creates, per device:

  • One Cloudflare Tunnel (cloudflare_zero_trust_tunnel_cloudflared)
  • Three DNS CNAME records, all pointing at the same tunnel:
  • <id>.tagai.uk — dashboard (proxies to Node-RED's HTTP port; intended as the read-only entry point)
  • <id>-nodered.tagai.uk — Node-RED editor (same HTTP port; gated to admins only at the Access layer, see below)
  • <id>-ssh.tagai.uk — SSH (proxies to port 22 over the tunnel)

The tunnel's ingress rules on the device itself (ansible/roles/cloudflared/templates/cloudflared-config.yml.j2) route both <id>.tagai.uk and <id>-nodered.tagai.uk to localhost:1880 (Node-RED), and <id>-ssh.tagai.uk to ssh://localhost:22.

Ansible's own SSH connectivity defaults to the device's LAN IP (local_host in inventory) and only routes over the Cloudflare Tunnel when invoked with -e use_tunnel=true (injects ProxyCommand="cloudflared access ssh --hostname %h"). Day-to-day fleet management assumes you're on the same network as the devices; the tunnel is the path for remote/off-network access.

Access control (Cloudflare Zero Trust)

terraform/modules/access/main.tf creates three separate Access applications, each with its own policy:

Application Domain pattern Who IdPs
Dashboard *.tagai.uk Admins + Users Google, email OTP, admin group
Node-RED *-nodered.tagai.uk Admins only GitHub org team, Google, admin group
SSH *-ssh.tagai.uk Admins only GitHub org team, Google, admin group

Because the more-specific *-nodered.tagai.uk and *-ssh.tagai.uk applications exist, they take precedence over the *.tagai.uk dashboard app for those hostnames — the dashboard app is effectively the fallback for plain <id>.tagai.uk requests. Full detail in security-access.md.

MQTT / EMQX

Node-RED on each device connects to EMQX at mqtts.tagai.xyz:8883 using per-device mTLS client certificates (2-year validity, generated by task mqtt:cert / scripts/provision-mqtt-certs.sh). See mqtt-emqx.md and reference/emqx-certificate-auth-setup.md.

Node-RED deployment model

Flows are managed via Node-RED's Projects feature: each device auto-clones ssh://git@github.com/mbTagai/gaisro-technika-nodered (or the lite equivalent) on first boot and can be updated in place via task deploy-nodered (ansible/playbooks/deploy-nodered-project.yml). See reference/nodered-git-deployment.md and reference/nodered-projects-setup.md.

Environments

Three tiers, promoted in order: developmentstagingproduction (plus a parallel lite variant of each). Deploy/health/restart tasks exist per tier (task ansible:deploy-dev, health-staging, restart-prod, ...) — see reference/ansible-environment-deployment.md.