SailEver · infra/oci · status 2026-10-23

Beta server infrastructure

The new server runs on three Oracle Cloud VMs in Amsterdam (eu-amsterdam-1, compartment sailever), built with OpenTofu from infra/oci/. The app VM sailever-beta (public subnet, only ports 80 and 443 open) runs kamal-proxy with Let's Encrypt and the Rails container for beta.sailever.com (website: sign-in, logbook, pairing page) and api.sailever.com (watch API). The DB VM sailever-db sits in a private subnet without a public IP: Postgres 16 with its data on a 50 GB block volume, reachable only from the app servers on 5432, over TLS that Rails checks (sslmode=verify-full against a private CA made by tofu). Uploaded files are in the OCI Object Storage bucket sailever-files; a nightly encrypted pg_dump goes to sailever-backups; mail goes out through OCI Email Delivery. The worker VM sailever-worker (private subnet, no inbound port, 2 OCPU / 12 GB) runs all background jobs: the Rails jobs role (Solid Queue, uploads first; the app VM keeps one fallback thread for them) and, at the lowest priority, the tile builds (depth layer and monthly basemap), which upload to R2. The move of the Rails jobs and the basemap build to the worker VM is in code, not deployed yet (ADR 011). Map tiles (with the depth layer) and the email logo come from Cloudflare R2, served on tiles.sailever.com (a Worker) and assets.sailever.com. SSH reaches each VM only through its own Cloudflare Tunnel behind Cloudflare Access (ssh / ssh-db / ssh-worker.sailever.com), for the owner (One-time PIN) and the deploy service token. www.sailever.com stays on the old Hetzner server until launch.

Status 2026-10-23: live since the first deploy on 2026-10-17; the worker VM since 2026-10-22, building the depth layer v2; WatchApp 1.1.0 on Garmin and Wear OS pairs and uploads from real watches (closed beta: only invited accounts can sign in). Two kinds of change: the app is deployed by GitHub Actions (Run workflow on main); infrastructure changes only by tofu on the owner's Mac.

Pick a view below. Every box is a button: click it (or Tab to it and press Enter) to see what it is, where it lives, which file defines it, its status and who can reach it. Related items in the panel jump to their box, in another view if needed.

  • Live 99
  • In code, not applied yet 2
  • Later: step 3.11 3
  • Dropped 2
  • Old site, until launch 2

Next

Still open in docs/plans/beta_server_next_steps.md:

  1. Watch releases (5.6, 6.5): Garmin 1.1.0 store beta, Wear OS to Play internal testing and review; then the end-to-end check 6.3 on both watches.
  2. Pairing redirect (5.2): sailever.com/pair reaches beta through the apex 301 to www and the old app's 302; the planned Cloudflare 302 rule is not active. At launch the new app on www serves /pair itself.
  3. Jobs on the worker VM (ADR 011): tofu apply (NSG rule db_postgres_worker), infra/oci/db-allow-worker.sh once on the DB VM, then the server deploy (role jobs) and the Worker deploy (basemap build).
  4. Launch (7.3): www moves to the new server, SAILEVER_BETA=false, beta → www 301, both /pair redirects to www, old app archived; then the beta applications go.
  5. Cleanup: 7.1 (old history backup), old Cloudflare token and the leaked age key (docs/credentials.md §8). Later (3.11): load balancer, second app server.

1. Traffic

Request paths. Web and API traffic resolves through Cloudflare DNS but goes straight to the app VM's public IP (DNS only, not proxied), where kamal-proxy holds the Let's Encrypt certificates. Map tiles and the email logo never touch the VMs: browsers fetch tiles from the Worker on tiles.sailever.com, mail apps load the logo from assets.sailever.com, both backed by Cloudflare R2. The DB VM has no public IP: only the app servers and the worker VM reach Postgres on 5432, over TLS. The worker VM takes no connection at all; it runs every background job (in code, not deployed yet: the Rails jobs role, uploads first, and the basemap build; live: the depth build, at the lowest priority), and its jobs only reach out: to Postgres inside the VCN, everything else through the NAT gateway. SSH never touches a public IP: each VM opens its own tunnel to Cloudflare from the inside (the private VMs through the NAT gateway), behind Cloudflare Access. Port 22 is closed everywhere.

Internet Cloudflare · R2 and Workers Owner's Mac GitHub Cloudflare · DNS, tunnels, Access Hetzner (old) Oracle Cloud · eu-amsterdam-1 · compartment sailever VCN sailever · 10.0.0.0/16 Subnet sailever-public · 10.0.1.0/24 App VM sailever-beta Subnet sailever-private · 10.0.2.0/24 DB VM sailever-db Worker VM sailever-worker Later · step 3.11 email logo map tiles binding www beta api 301 to www 443/80 to VM IP 0.0.0.0/0 5432 · TLS verify-full /var/lib/sailever One-time PIN service token Access checks first allowed Access checks first allowed SSH via tunnel (VM dials out) localhost:22 SSH via tunnel localhost:22 5432 · TLS via NSG db TCP 22: no rule anywhere NSG worker: no inbound rule Mail appsload the email logo Web visitorsbrowsers Watchesapi.sailever.com R2 assets.sailever.compublic · email logo Worker tiles.sailever.comtiles.json · depth · seamark R2 sailever-tilesbasemap + depth · weur SSH clientvia cloudflared Deploy jobRun workflow, main only www A → Hetzner157.90.174.214 · DNS only sailever.com → wwwredirect rule · 301 beta / api A → app VMDNS only · TTL 300 ssh CNAME → tunnelproxied · app VM Access app ssh.sailever.comself-hosted · 24 h login Tunnel sailever-betassh.sailever.com → :22 Email Routingsupport@ → owner's Gmail DKIM CNAMEsailever-ams-202610 ssh-db CNAME → tunnelproxied · DB VM Access app ssh-dbsame two policies Tunnel sailever-dbssh-db.sailever.com → :22 Hetzner 157.90.174.214old site, until launch /pair → beta (302)old app on www Internet gatewayroute 0.0.0.0/0 NSG appin: 80, 443 Security listegress + ICMP only NSG db5432 · app + worker NAT gatewayoutbound only Service gatewayOCI services · 3.11 iptables app VMopens 80/443, drops 22 kamal-proxyLet's Encrypt · beta + api Rails apptrahfo/sailever-server cloudflaredtunnel sailever-beta sshd localhost:22key only · user deploy iptables DB VM5432 from both subnets Postgres 1610.0.2.10:5432 · TLS cloudflaredtunnel sailever-db sshd localhost:22key only · user deploy Rails jobs roleSolid Queue Depth buildworker VM → R2 Basemap buildmonthly · worker VM → R2 Load balancer10 Mbps · Always Free Second app VMA1 used by worker Private app subnetapp VMs move here Bucket sailever-filesActive Storage · S3 Bucket sailever-backupsprivate · 30 days Email Deliverydomain, DKIM, sender Volume sailever-data50 GB · outlives VMs

2. Accounts & keys

Who can do what. The password manager is the source of truth (docs/credentials.md); the Mac holds short-lived copies for tofu, GitHub Actions hold copies as repository secrets. All three VMs take the same deploy key, each behind its own tunnel and Access application with the same two policies. The Rails app acts as its own OCI user sailever-app with two keys; the tile builds on the worker VM write to R2 with a token limited to one bucket, which only the Worker deploy hands to them. No secret values appear on this page.

Owner Owner's Mac Oracle Cloud · eu-amsterdam-1 App VM sailever-beta DB VM sailever-db Cloudflare GitHub Oracle Cloud · user sailever-app Cloudflare R2 copied into env (read -s) signs as S3 key of member of granted by manage in compartment CLOUDFLARE_API_TOKEN copies for CI public key issues included in guards guards tunnel token in cloud-init One-time PIN to owner email admin · 2FA passkey copy as repo secret read by push / pull image granted by (group) keys of copy repo secrets Ownerthe only admin Password managersource of truth for secrets tofu (OpenTofu)plan / apply, Mac only OCI API key~/.oci/config · SAILEVER Customer Secret KeyS3 key for the state Deploy SSH key~/.ssh/sailever_deploy SSH clientvia cloudflared Tenancyhome region eu-amsterdam-1 Compartment saileverholds every resource Policy tofu-sailevergroup → compartment sailever Group tofu-adminsmember: tofu User tofuAPI key + Customer Secret Key Policy objectstorage-lifecyclelets OS delete old objects User deploy (all VMs)docker group · sudo sshd localhost:22key only · user deploy cloudflaredtunnel sailever-beta Postgres CA + server certtls.tf · ECDSA P-256 Postgres TLS filesca · server cert · key sshd localhost:22key only · user deploy cloudflaredtunnel sailever-db API token (tofu)8 permissions Zone sailever.comNS felipe / jasmine Zero Trust teamsailever · One-time PIN Service tokengithub-actions-deploy · 1 year Policy: ownerallow · email Policy: deployservice token Access app ssh.sailever.comself-hosted · 24 h login Tunnel sailever-betassh.sailever.com → :22 Access app ssh-dbsame two policies Tunnel sailever-dbssh-db.sailever.com → :22 Repo trahfo/saileverprivate · GitHub Free Actions settingsallow-list · SHA pins Repository secrets12 names · copies Deploy jobRun workflow, main only GHCR (private)trahfo/sailever-server Rulesetsdropped 1.5 Environmentsdropped 1.6 worker.ymltest · deploy Policy sailever-appfiles · backups · email User sailever-appgroup sailever-app · Default Customer Secret Keyuser sailever-app · S3 SMTP credentialsuser sailever-app R2 API tokenR/W on sailever-tiles only

3. Data & backups

Where data lives and how it is copied. Postgres runs on the DB VM with its data on the 50 GB data volume, which survives a VM replacement. Uploaded files are in the Object Storage bucket sailever-files, so the app VM keeps no data. Two independent copies of the database: weekly volume backups inside OCI, and a nightly age-encrypted pg_dump that a Rails job writes to sailever-backups (write-only; in code it runs in the jobs role on the worker VM, today still in Puma on the app VM); the owner restores them on the Mac. The tofu state is a separate, encrypted object; it also holds the Postgres TLS keys. The R2 buckets on Cloudflare hold only rebuildable or git-tracked files and are in no backup.

Owner Owner's Mac Oracle Cloud · eu-amsterdam-1 · compartment sailever App VM sailever-beta Worker VM sailever-worker DB VM sailever-db Cloudflare R2 · no backup files (S3 API) SQL · TLS pg_dump encrypt one PutObject · write-only deletes after 30 days runs in data dir Sun 02:00 UTC · 27 days cloud-init writes keys in state stored in (versioned) private key: password manager + offline read / write, encrypted on the Mac OCI CLI (profile SAILEVER): download, then age decrypt Password managersource of truth for secrets tofu (OpenTofu)plan / apply, Mac only Backup restore toolsOCI CLI · age · Mac Rails apptrahfo/sailever-server Docker (app VM)Rails · kamal-proxy Rails jobs roleSolid Queue Nightly backup job03:00 UTC · Rails jobs Postgres 1610.0.2.10:5432 · TLS Postgres TLS filesca · server cert · key Docker (DB VM)needs the data mount /var/lib/sailevermount script · DB VM Volume sailever-data50 GB · outlives VMs Volume attachmentto the DB VM Weekly volume backups≤ 4 kept · Always Free 5 Bucket sailever-filesActive Storage · S3 age keypublic in deploy.yml · private offline Bucket sailever-backupsprivate · 30 days Policy objectstorage-lifecyclelets OS delete old objects Postgres CA + server certtls.tf · ECDSA P-256 Bucket sailever-tofu-stateprivate · versioned tofu stateoci/terraform.tfstate · AES-GCM R2 sailever-tilesbasemap + depth · weur R2 assets.sailever.compublic · email logo

4. Changes & deploys

How a change travels. Every commit and push passes mise guard locally; GitHub re-runs the checks in CI but can't block (GitHub Free). The app deploys only when the owner presses Run workflow on main: Kamal goes through the Access applications, the web role to the app VM, the jobs role to the worker VM, the Postgres accessory to the DB VM. The map tiles are built on the worker VM and uploaded to R2, the depth layer every 30 days and the basemap monthly, in a low-priority container the Worker workflow deploys; tiles.yml only runs the Worker's tests. The jobs role and the basemap build are in code, not deployed yet. The documentation is built by Cloudflare Pages on every push to main and shown only to the owner, behind Access. The email logo is uploaded by hand with wrangler. Infrastructure, including the Worker code, changes only by tofu on the owner's Mac.

Owner's Mac GitHub Cloudflare Oracle Cloud · eu-amsterdam-1 App VM sailever-beta DB VM sailever-db Worker VM sailever-worker Cloudflare · R2 and Workers Cloudflare Pages · docs blocks bypasses, hand-run kamal git push Run workflow on main secrets image kamal deploy over SSH tunnel → app VM accessory new container pull kamal deploy role jobs on push owner only retried the first apply kamal deploy ssh-worker tunnel depth layer, every 30 days basemap, monthly wrangler r2 object put tofu apply (OCI + Cloudflare), never in CI mise guard agentClaude Code PreToolUse hook git + mise guard hooks.githooks → mise guard retry-apply.shdone · kept for next time tofu (OpenTofu)plan / apply, Mac only wrangler r2 object putby hand · logo Repo trahfo/saileverprivate · GitHub Free Actions settingsallow-list · SHA pins guard.ymlevery push · weekly full scan server.ymlquality gate + tests android.ymltest · build · lint tracksim.ymlpytest Dependabotone PR per ecosystem/month Deploy jobRun workflow, main only Repository secrets12 names · copies GHCR (private)trahfo/sailever-server Rulesetsdropped 1.5 Environmentsdropped 1.6 tiles.ymltests only worker.ymltest · deploy Access app ssh-dbsame two policies Access app ssh.sailever.comself-hosted · 24 h login Unattended upgradesall VMs · reboot 03:30 / 05:30 kamal-proxyLet's Encrypt · beta + api Rails apptrahfo/sailever-server Postgres 1610.0.2.10:5432 · TLS Rails jobs roleSolid Queue Depth buildworker VM → R2 Basemap buildmonthly · worker VM → R2 Compartment saileverholds every resource R2 sailever-tilesbasemap + depth · weur Worker tiles.sailever.comtiles.json · depth · seamark R2 assets.sailever.compublic · email logo Pages sailever-docsdocs from main · private Access app docsowner email only

Wide diagrams scroll sideways on small screens. Arrow styles follow the status of their ends: solid = live, dashed blue = created but not in use or in code only, long-dashed grey = later (step 3.11), dotted = old site.

All components (108)

Every box from the four views in one list, grouped by where it lives, with its status and the main place it is defined. Names open the details.

ComponentStatusDefined in
Owner and Mac
Liveinfra/oci/settings.tf:14
Livedocs/credentials.md
Liveinfra/oci/versions.tf:60-65
Liveinfra/oci/retry-apply.sh:1-40
Liveinfra/oci/README.md:86-93
Liveinfra/oci/versions.tf:73-77
Livetools/guard/guard.py:107
Livetools/guard/guard.py:189-200
Liveinfra/assets/README.md:15-23
Liveapps/server/bin/backup-restore-test
Liveinfra/oci/versions.tf:62
Liveinfra/oci/versions.tf:23-39
Liveapps/server/config/deploy.yml:63-64
Cloudflare: DNS, tunnels, Access
LiveMade by hand (plan step 2.3, 2.5)
Liveinfra/oci/cloudflare.tf:5-15
Old site, until launchMade by hand (plan step 2.3)
LiveMade by hand in the dashboard (plan step 2.5, 5.2)
LiveMade by hand (plan step 2.3)
Liveinfra/oci/storage.tf:93-101
Liveinfra/oci/cloudflare.tf:51-59
Liveinfra/oci/cloudflare.tf:149-157
Liveinfra/oci/cloudflare.tf:18-20
Liveinfra/oci/cloudflare.tf:116-118
Liveinfra/oci/cloudflare.tf:100-112
Liveinfra/oci/cloudflare.tf:159-171
Liveinfra/oci/cloudflare.tf:75-82
Liveinfra/oci/cloudflare.tf:84-98
LiveMade by hand (plan step 2.6)
LiveMade by hand (plan step 2.6)
Livedocs/credentials.md §2
Livetools/docs/README.md
Livetools/docs/README.md
Cloudflare: R2 and Workers
Liveinfra/tiles/worker.js:1-13
Liveinfra/oci/tiles.tf:6-10
Liveinfra/oci/assets.tf:5-9
Livedocs/credentials.md §3
Oracle Cloud: accounts and network
LiveMade by hand (plan step 2.1, 2.2)
LiveMade by hand (plan step 2.2)
LiveMade by hand (plan step 2.2)
LiveMade by hand (plan step 2.2)
LiveMade by hand (plan step 2.2, 3.9)
Liveinfra/oci/storage.tf:11-18
Liveinfra/oci/iam.tf:1-4
Liveinfra/oci/iam.tf:23-37
Livedocs/credentials.md §3
Livedocs/credentials.md §3
Liveinfra/oci/network.tf:27-32
Liveinfra/oci/network.tf:34-39
Liveinfra/oci/network.tf:53-73
Liveinfra/oci/network.tf:75-83
Liveinfra/oci/network.tf:150-159
Liveinfra/oci/network.tf:86-90
Liveinfra/oci/network.tf:92-108
Liveinfra/oci/network.tf:162-166
Liveinfra/oci/network.tf:188-193
Oracle Cloud: VMs, storage, mail
Liveinfra/oci/vm.tf:37-88
Liveinfra/oci/vm.tf:92-140
Liveinfra/oci/vm.tf
Liveinfra/oci/vm.tf:198-207
Liveinfra/oci/vm.tf:209-214
Liveinfra/oci/vm.tf:218-231
Liveinfra/oci/storage.tf:2-8
Liveinfra/oci/storage.tf:40-50
Liveinfra/oci/bootstrap/main.tf:18-28
Liveinfra/oci/versions.tf:26-39
Liveinfra/oci/tls.tf:10-50
Liveinfra/oci/storage.tf:71-74
On the app VM
Liveinfra/oci/cloud-init.yaml.tftpl:56-74
Liveinfra/oci/cloud-init.yaml.tftpl:22-23
Liveinfra/oci/cloud-init.yaml.tftpl:147-152
Liveinfra/oci/cloud-init.yaml.tftpl:143-146
Liveapps/server/config/deploy.yml:15-22
Liveapps/server/config/deploy.yml:5-8
Liveapps/server/config/recurring.yml:22-25
On the DB VM
Liveinfra/oci/cloud-init.yaml.tftpl:75-86
Liveinfra/oci/cloud-init.yaml.tftpl:22-23
Liveinfra/oci/cloud-init.yaml.tftpl:147-152
Liveinfra/oci/cloud-init.yaml.tftpl:100-105
Liveinfra/oci/cloud-init.yaml.tftpl:106-127
Liveinfra/oci/cloud-init.yaml.tftpl:86-99
Liveapps/server/config/deploy.yml:85-115
On the worker VM
In code, not applied yetapps/server/config/deploy.yml:14-26
Liveinfra/worker/config/deploy.yml:36-50
In code, not applied yetinfra/basemap/README.md
On every VM
Liveinfra/oci/cloud-init.yaml.tftpl:10-20
Liveinfra/oci/cloud-init.yaml.tftpl:25-30
Later: load balancer (step 3.11)
Later: step 3.11docs/plans/beta_server_next_steps.md 3.11
Later: step 3.11docs/plans/beta_server_next_steps.md 3.11
Later: step 3.11infra/oci/README.md:114
GitHub
Livedocs/plans/monorepo_plan.md §6.4
LiveSet by hand (plan step 1.2)
Live.github/workflows/guard.yml
Live.github/workflows/server.yml:26-35
Live.github/workflows/android.yml
Live.github/workflows/tracksim.yml
Live.github/workflows/tiles.yml:1-4
Live.github/workflows/worker.yml:1-8
Live.github/dependabot.yml
Live.github/workflows/server.yml:69-168
Livedocs/credentials.md §3
Liveapps/server/config/deploy.yml:7
Droppeddocs/plans/beta_server_next_steps.md 1.5
Droppeddocs/plans/beta_server_next_steps.md 1.6
Outside
Liveapps/server/config/deploy.yml:47-49
Liveapps/server/app/views/layouts/mailer.html.erb:11
Liveapps/garmin-watchapp/source/Config.mc:16
Old site, until launchdocs/plans/monorepo_plan.md §7.6
Livedocs/plans/beta_server_next_steps.md 5.2