The reference deployment is Fly.io with scale-to-zero.
# 1. Pick a domain. Anywhere with a DNS panel works.
# Suggested host: greenbutton.<your-domain>
#
# 2. Sign up at fly.io, install flyctl.
cd server/
fly launch --no-deploy --copy-config --name open-green-button --region yyz
# 3. Generate and set secrets. Both are 32-byte random base64 strings.
fly secrets set \
OPENGB_CRYPTO_AESKEYBASE64=$(openssl rand -base64 32) \
OPENGB_CRYPTO_HMACPEPPERBASE64=$(openssl rand -base64 32) \
OPENGB_PUBLIC_BASE_URL=https://greenbutton.<your-domain>
# 4. Attach your custom hostname.
fly certs add greenbutton.<your-domain>
# Add the AAAA + A records that fly prints, then:
fly certs check greenbutton.<your-domain>
# 5. Build and deploy. See "Building the image" below — the default is a GraalVM
# native image built from the repo-root Dockerfile.The server compiles to a self-contained GraalVM native binary: sub-second cold start and a small
memory footprint, which pairs well with Fly's scale-to-zero. The Dockerfile at
the repo root is a multi-stage build — it runs :app:nativeCompile inside the GraalVM container
(no local GraalVM needed) and packages the binary onto a slim distroless/base-debian12.
The Ktor CIO engine is the only one that works under native-image; that commitment is documented
in CioKtorService.kt. The
native build is wired up by the org.graalvm.buildtools.native Gradle plugin in
server/app/build.gradle.kts.
# Run from the REPO ROOT (the build context needs ../branding). Fly builds the Dockerfile on
# its remote builder, pushes to registry.fly.io, and releases — one command.
fly deploy --config server/fly.toml --dockerfile DockerfileTo build the native binary locally (mise supplies GraalVM for JDK 24 via GRAALVM_HOME — see
mise.toml — so no manual setup beyond mise install):
cd server
./gradlew :app:nativeCompile # → app/build/native/nativeCompile/opengb-server
./gradlew :app:nativeRun # build + run it directlyNative-image needs to know about reflection, resources, and dynamic proxies ahead of time.
Most of it comes from the GraalVM reachability-metadata repository (enabled in the Gradle config)
for our dependencies; the app-specific remainder is committed under
server/app/src/main/resources/META-INF/native-image/org.opengb/ in three subdirectories
(native-image unions them at build time):
from-tests/— generated by-Pagent test+metadataCopy. Covers what the in-memory test engine exercises: crypto, kotlinx.serialization, the OAuth flow, route handlers. (No real sockets — tracing socket teardown is non-deterministic and would make the CI guard flaky.)from-app/— generated by tracing a realboot()under the agent. Covers what the test engine can't: the CIO network selector (readHandlerReference), log4j2's JSON-config plugin reflection, Hoplite's config decode, Bootable startup.manual/— hand-maintained. Holds Bootable'sStopSignalHandler, whose reflection happens on the OS-signal shutdown path that the agent can't observe. Edit by hand if shutdown-path reflection regresses.
Regenerate from-tests/ + from-app/ with the helper script. It needs a GraalVM for JDK 24
(for the native-image-agent), which mise provides via GRAALVM_HOME — so with mise active and
mise install done, just run:
./scripts/generate-native-metadata.shRe-run after dependency or code changes that touch reflection-heavy paths, rebuild the native
image, and confirm the binary still boots and serves before committing the regenerated files —
a missing entry only fails at runtime, not at build time. CI enforces this: the
native-image-metadata job in server-ci.yml regenerates
from-tests/ + from-app/ and fails if they differ from what's committed. The reliable
correctness check is still the container actually booting:
podman build -t opengb-native -f Dockerfile . # from the repo root
podman run --rm -p 8080:8080 opengb-native # then: curl localhost:8080/health(cd server && ./gradlew :app:nativeRun also works, but only in a shell whose environment has no
variables containing ${...} — e.g. exported shell functions from Lmod/environment-modules — which
Hoplite's config loader will try to resolve and fail on. The Fly machine's environment is clean.)
After 5 minutes of idle:
fly status # machine state should be "stopped"
curl -i https://greenbutton.<your-domain>/health
# first request wakes the machine: ~sub-second with the native image (a JVM image
# would take ~5-15s on shared-cpu-1x 256MB). Subsequent calls within the keep-alive
# window respond in ms.When you register the app with a new utility (Burlington Hydro, etc.), the utility issues a client_id and client_secret. Set them as Fly secrets named per the utility:
fly secrets set \
OPENGB_UTILITY_BURLINGTON_HYDRO_CLIENTID="..." \
OPENGB_UTILITY_BURLINGTON_HYDRO_CLIENTSECRET="..."The server's utilities.conf reads these via Hoplite env substitution.
.github/workflows/deploy.yml runs on every push to master that touches server/**, branding/**, or the Dockerfile. It runs ./gradlew build (compile + tests) as a gate, then flyctl deploy --config server/fly.toml --dockerfile Dockerfile --remote-only — compiling the GraalVM native image on Fly's remote builder and releasing it, with OPENGB_VERSION stamped to the commit SHA. (Native-image compilation is slower and more memory-hungry than a plain JVM build, so expect longer CI runs — the payoff is the sub-second startup above.)
One-time setup — generate a deploy token and store it as a repo secret:
fly tokens create deploy -x 8760h # 1 year, scoped to deploy only
# copy the token (starts with "FlyV1 ...")Then in GitHub: Settings → Secrets and variables → Actions → New repository secret
- Name:
FLY_API_TOKEN - Value: paste the token from
fly tokens create
The token can be rotated at any time with fly tokens revoke + a fresh fly tokens create. Avoid using your personal fly auth token for CI — deploy tokens are scoped narrower and easier to revoke.
To deploy manually without a commit: Actions tab → "deploy" workflow → "Run workflow".
To roll back: flyctl releases --app open-green-button to find a prior release and its image ref, then flyctl deploy --app open-green-button --image <image-ref> (Fly tags native builds itself — e.g. registry.fly.io/open-green-button:deployment-… — rather than by commit SHA, so copy the ref from flyctl releases output).