Deployment
Trident runs on two clouds. Cloudflare hosts everything at the edge: the app’s web build, the admin console, and the partner reference implementations. DigitalOcean hosts the stateful core: the API, its database, and Keycloak. Only a test environment exists today.
Deployment diagram
Section titled “Deployment diagram”flowchart TB
classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
classDef staff fill:#FDE9E2,stroke:#E9724C,color:#12191B
classDef person fill:#E9724C,stroke:#C55A37,color:#ffffff
classDef store fill:#ffffff,stroke:#13686C,color:#12191B
subgraph devices["Devices"]
phone["<b>Guest phone</b><br/>FINNS app"]:::ours
browser(["Guest browser"]):::person
staffpc(["Staff browser"]):::person
gatedev["<b>Gate reference</b><br/>Android app"]:::ext
end
subgraph cf["Cloudflare Workers"]
appweb["<b>trident-app-web</b><br/>Flutter web, static"]:::ours
bookw["<b>Booking reference</b><br/>Worker + D1"]:::ext
gamw["<b>Engagement partner reference</b><br/>Worker + D1 + widget page"]:::ext
adminw["<b>trident-admin</b><br/>SvelteKit SSR"]:::staff
end
subgraph do["DigitalOcean"]
subgraph drop["Droplet keycloak-1"]
caddy["<b>Caddy</b> TLS"]:::ext
kc["<b>Keycloak 26.8</b><br/>+ finns-core extension"]:::ext
end
subgraph apl["App Platform"]
core["<b>core</b><br/>Quarkus native container"]:::ours
end
subgraph pgc["Managed PostgreSQL 18"]
kcdb[("keycloak db")]:::store
coredb[("core db")]:::store
end
end
jc["<b>JumpCloud</b><br/>staff SSO"]:::ext
browser --> appweb
browser --> bookw
staffpc --> adminw
phone --> gamw
phone --> core
phone --> caddy
gatedev --> core
adminw --> jc
adminw --> core
bookw --> core
gamw --> core
bookw --> caddy
caddy --> kc
kc --> kcdb
kc <--> core
core --> coredb
- FINNS platform built and run by FINNS
- Vendor or third party built or bought outside FINNS, to our contract
- Staff tool used by FINNS staff, not guests
- Person guest or staff
Operators reach the Keycloak droplet over Tailscale. This portal is a static Worker (trident-devportal) alongside the others.
Hosts and domains (test)
Section titled “Hosts and domains (test)”| Component | Host | Platform | State |
|---|---|---|---|
| Core | test-api.finnsbali.com |
DigitalOcean App Platform, app core |
Stateless; all state in Postgres |
| Keycloak | test-auth.finnsbeachclub.com |
Droplet keycloak-1, Docker Compose, Caddy for TLS |
Its own database in the shared Postgres cluster |
| PostgreSQL 18 | private, sslmode=require |
DigitalOcean Managed Postgres | Core and Keycloak databases |
| Admin console | test-admin.finnsbali.com |
Cloudflare Worker trident-admin |
None (sealed cookie sessions) |
| Guest app (web) | trident-app-web.juna.workers.dev |
Cloudflare Worker, static assets | None |
| Booking reference | trident-poc-booking.juna.workers.dev |
Cloudflare Worker + D1 | Sessions in D1 |
| Engagement partner reference | trident-poc-gamification.juna.workers.dev |
Cloudflare Worker + D1 | Widget sessions in D1 |
| Guest app (mobile) | builds by hand, flavors prod and test |
iOS, Android | Tokens in Keychain / Keystore |
| Gate reference | Android app | Android | None |
How code ships
Section titled “How code ships”Every repository deploys itself from main; pull requests get a preview where the platform supports one.
flowchart LR
classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
classDef step fill:#ffffff,stroke:#D9CFBE,color:#12191B
subgraph coreci["core · GitHub Actions on Blacksmith"]
direction LR
c1["PR: JVM tests<br/>+ smoke tests"]:::step --> c2["main: GraalVM native build<br/>smoke tests on the binary"]:::step --> c3["Push image to GHCR<br/>:latest and :sha"]:::step --> c4["App Platform<br/>pulls and rolls out"]:::ours
end
subgraph cfci["admin · app web · booking · engagement"]
direction LR
w1["PR: build<br/>wrangler versions upload<br/>preview alias pr-N"]:::step
w2["main: build, D1 migrations<br/>wrangler deploy"]:::step --> w3["Cloudflare<br/>Workers"]:::ours
end
subgraph manual["By hand"]
direction LR
m1["Flutter build<br/>APK / AAB / IPA"]:::step --> m2["Test devices"]:::ext
k1["Keycloak extension jar<br/>deploy.sh over Tailscale"]:::step --> k2["keycloak-1"]:::ext
end
| Pipeline | Trigger | What it does |
|---|---|---|
| core | PR | JVM unit and integration tests, then smoke tests |
| core | push to main |
Native image (Mandrel, container build) → smoke tests against the binary → ghcr.io/…:latest and :<sha> (UBI 9 micro base, non-root) |
| Workers (admin, app web, booking, engagement) | PR | Build and wrangler versions upload --preview-alias pr-<n>; production untouched |
| Workers | push to main |
Typecheck, tests, D1 migrations (partners), wrangler deploy |
| Mobile apps | manual | flutter build per flavor; no mobile CI yet |
| Keycloak extension | manual | Build the jar, copy to keycloak-1, restart |
Configuration and secrets
Section titled “Configuration and secrets”- Core reads everything from environment variables (encrypted in App Platform) and refuses to start if a required secret is missing or shorter than 32 characters, or a partner URL isn’t HTTPS.
- Workers keep plain settings in
wrangler.jsoncand secrets as Worker secrets (wrangler secret put), never in the repository. - Keycloak reads the extension’s settings from
KC_SPI_EVENTS_LISTENER__FINNS_CORE__*variables. - Every integration has two secrets per direction pair: the token it presents to core, and the token core presents back. See Security model.
Capacity notes
Section titled “Capacity notes”- The connection pool is sized at 10 for the small managed Postgres plan; core is stateless and can scale out horizontally, and its scheduled jobs are safe to run on several instances (advisory locks or idempotent work).
- Keycloak runs as a single droplet today, and its database shares the cluster with core.
- The brief’s target is about 150,000 app users in year one. Neither the Postgres plan nor the single Keycloak node has been sized for that yet; see Roadmap.
Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).

