Deploy contract
This page is the full reference for deploying a web game or app on gbandit. For a guided path, see use the template or deploy an existing app.
The short version:
- Add a
gbandit.jsoncat the repo root declaring your components. - A
backendcomponent's container must listen on$PORT(8080). - A
frontendcomponent's image must export a static site. - Run
gbandit deploy --create. - Once it works in dev, run
gbandit promote. - To list it on games.gbandit.com, upload a cover with
gbandit project cover <image>and rungbandit project publish.
Every project gets two environments, dev and prod, at https://dev-<project>.gbandit.com and https://<project>.gbandit.com. gbandit deploy builds your project and rolls it out to dev; it takes no environment, because dev is the only one a build can reach. gbandit promote points prod at the release dev is running and rolls it out. Nothing is built, so prod serves byte for byte the images you verified in dev. A snapshot of prod's database is taken before the rollout. To roll back, deploy the earlier code to dev and promote it. Promote to prod on the project's Deployments page does the same from the dashboard, and shows which commit prod runs and which it moves to.
gbandit.jsonc
Project configuration at the repo root. The file is JSONC: comments and trailing commas are allowed.
jsonc
{
"project": "my-game",
"title": "My Game",
"frontend": { "dockerfile": "frontend/Dockerfile", "context": "frontend" },
// Remove or comment out "backend" for a static-only project.
"backend": { "dockerfile": "backend/Dockerfile", "context": "backend", "volume": { "sqlite": true } },
"local_dev": {
"auto_commit": false,
},
}| Field | Required | Meaning |
|---|---|---|
project | yes | Project slug, which becomes the subdomain: 1 to 55 lowercase letters, digits and hyphens, starting and ending with a letter or digit, and not starting with dev-, stage- or prod-. |
title | no | Display title. When present, deploy keeps the platform title in sync. Omit to manage the title in the web UI. |
frontend | no* | Static frontend component: { "dockerfile": <path>, "context": <dir> }. See Components. |
backend | no* | Backend component: { "dockerfile": <path>, "context": <dir> }, optionally with "volume": { "sqlite": true } for a SQLite database on its own disk. See Components and Databases. |
database | no | { "postgres": true } for a managed Postgres database. Requires a backend, and cannot be combined with a volume. Omit the section entirely for no managed database. Removing it removes a provisioned database; see Databases. |
local_dev.auto_commit | no | Default false: deploy never touches git. See Git behaviour. |
* At least one of frontend and backend must be declared.
Unknown fields are rejected, so a misspelled field fails the deploy instead of being ignored. The config is validated when you run gbandit deploy, before anything is built.
Components
The frontend and backend objects declare what the project consists of. Presence is the switch: a declared component is built and deployed; an undeclared one is not. Deploying with a component removed also removes it from the environment.
Each component has two required fields, both paths relative to the repo root:
dockerfile: the Dockerfile to build.context: the docker build context directory.COPYinstructions resolve inside it. Use the component's own directory for a conventional layout, or"."(the repo root) when backend and frontend share code the Dockerfiles need toCOPY.
The dockerfile does not have to live inside the context. If backend or frontend isn't declared but backend/Dockerfile or frontend/Dockerfile exists, the deploy output warns that it was skipped.
Backend Dockerfile
The backend component's Dockerfile is built as it is, so any language works. The resulting container must:
- Listen on
$PORT(injected, currently8080). Hardcoding 8080 also works, but readingPORTis the portable choice. - Only expect
DATABASE_URLif a database is declared. The variable is set exactly whengbandit.jsoncdeclares avolumeon the backend or adatabase(see Databases); otherwise it is unset. - Survive the runtime hardening below. Most importantly, the process runs as UID 65534 with a read-only root filesystem, so it must not write outside
/tmp.
Readiness is a TCP probe on the listen port: the app is "up" as soon as it accepts connections.
Static frontend
The frontend component's Dockerfile must produce an image containing the built static site, with index.html at the image root, /out, or /app/dist. Any bundler works. Example:
dockerfile
FROM oven/bun:1-alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM scratch
COPY --from=builder /app/dist /The files are published to the platform's release store and served by the platform's static origin. The frontend image is never run as a container. Content types are derived from file extensions (any file type works). The static site keeps being served while the backend hibernates.
Extensionless routes fall back to index.html (SPA routing works).
Your index.html should load the gbandit runtime with <script defer src="/__gbandit/runtime.js"></script> in <head>. The platform never adds it for you. A deploy whose index.html doesn't load it succeeds but warns, because without it players aren't counted, browser errors don't reach gbandit logs frontend, and the keyboard doesn't reach the game in the platform's preview.
Routing
On your project's domain:
/api/*→ your backend. WebSockets work under/api. If the project has no backend,/api/*returns 404./__gbandit/*→ reserved for the platform: the gbandit runtime your frontend loads, and what it talks to.- everything else → the static frontend. Backend-only projects (no
frontenddeclared) route everything to the backend instead, so self-serving apps (SSR frameworks, classic server-rendered apps) work.
Backends hibernate when idle. The static frontend keeps serving; the first /api request wakes the backend (held, not dropped, while it starts). For backend-only projects, wake-on-request applies to every path.
Runtime hardening
Backends run untrusted code, so the runtime is locked down. Your image must work with all of this:
- Runs under gVisor (syscall-filtering sandbox).
- Forced UID 65534 regardless of the image's
USER. Don't require root, and don't write to$HOME. - Read-only root filesystem. Writable paths:
/tmp(1Gi, wiped on restart) and/data(persistent, sqlite projects only). - 256Mi memory / 500m CPU limits (512Mi for sqlite projects). JVM/.NET images need their heap flags tuned.
- Exactly 1 replica. There is no horizontal scaling.
- No Kubernetes service-account token.
- Egress: public internet allowed, private network ranges blocked.
Databases
There are two ways to get a database, declared in different places in gbandit.jsonc. Your app owns the schema either way (see Migrations).
Use SQLite unless your app needs Postgres-specific features. A sleeping SQLite backend wakes faster, since there is no database cluster to start, and the backend gets a higher memory limit.
- A volume on the backend:
"backend": { ..., "volume": { "sqlite": true } }. The backend gets a persistent disk at/dataandDATABASE_URL=sqlite:///data/db.sqlite. Memory limit rises to 512Mi. Only the SQLite form exists:"sqlite": false(a plain disk) is rejected. - A managed database on the project:
"database": { "postgres": true }. Each environment gets its own database on a managed Postgres cluster, andDATABASE_URLis injected. Requires abackend."postgres": falseis rejected; omit the section instead.
A backend cannot have both, since it gets one DATABASE_URL. A project without a database declares neither and gets no DATABASE_URL.
Removing a database
Removing the volume or the database declaration on a project with a provisioned database removes that environment's database the next time that environment rolls the change out. This is permanent data loss, so it is guarded: the run is rejected unless you confirm it (interactive runs ask instead). A final backup is attempted before anything is dropped, but a failed backup never blocks the removal, so a broken database can always be removed.
Each environment is removed on its own, by the command that reaches it:
sh
gbandit deploy --confirm-database-removal # removes dev's database
gbandit promote --confirm-database-removal # removes prod's databaseDev's database goes when you deploy the change. Prod's stays until you promote that release; until then prod keeps running the release that still declares it, and keeps its data.
Switching from one to the other is the removal of the old database plus a fresh new one, and needs the same confirmation. Nothing is copied across.
Migrations
The platform never runs a migrator. Your app applies its own schema when it starts, using DATABASE_URL. The template compiles its backend/migrations/ into the binary and runs the pending ones at boot. With Prisma, Django, Rails and the like, run the migrator in the container's start command, for example prisma migrate deploy && node server.js.
A migration that fails at boot takes the container down, and the deploy fails with your app's own error in its output. With managed Postgres the previous version keeps serving. A SQLite backend stops the old version before the new one starts, since only one of them can hold the disk, so it is down until the next deploy fixes the migration.
The build receives no database connection. The template's build replays the migrations into a throwaway schema file on its own, so compile-time query checks work without one; a Postgres build that needs a live server for that has to bring its own (see below).
Upgrading to managed Postgres
The template ships SQLite. Moving a project to managed Postgres is four changes, and the same docker build keeps working on your own machine:
gbandit.jsonc: deletevolumefrom the backend and add"database": { "postgres": true }. This is a removal plus an addition, so the deploy needs--confirm-database-removaland nothing is copied across (see Removing a database).backend/Cargo.toml: swap sqlx'ssqlitefeature forpostgres.backend/src/main.rsandlib.rs:PgPoolinstead ofSqlitePool; drop the WAL journal option, keepsqlx::migrate!(). Rewrite the migrations that use SQLite-only syntax.backend/Dockerfile: the build still has to typecheckquery!against a migrated schema with no connection from outside, so the builder stage runs a throwaway Postgres of its own. Install andinitdbit beforeCOPYso both layers are cached; only the build step starts it.
dockerfile
FROM rust:1-slim-bookworm AS builder
WORKDIR /app
# Throwaway Postgres for the build. Installed and initialised before COPY so
# the layers survive source changes (about 30s and 370MB, once).
RUN apt-get update && apt-get install -y --no-install-recommends postgresql && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /pgdata && chown postgres /pgdata && \
su postgres -c "/usr/lib/postgresql/15/bin/initdb -D /pgdata --auth=trust --no-locale -E UTF8"
COPY . .
# Start the server on a unix socket only, replay the migrations into it, and
# point sqlx at it for the build. Costs well under a second per build.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
su postgres -c "/usr/lib/postgresql/15/bin/pg_ctl -D /pgdata -o '-k /tmp -c listen_addresses= -c fsync=off' -w start" && \
for f in migrations/*.sql; do [ -e "$f" ] || continue; su postgres -c "psql -v ON_ERROR_STOP=1 -h /tmp -d postgres -f $f" || exit 1; done && \
DATABASE_URL="postgres://postgres@localhost/postgres?host=/tmp" cargo build && \
cp target/debug/replace_with_project /app/replace_with_project
FROM gcr.io/distroless/cc-debian12
WORKDIR /app
COPY --from=builder /app/replace_with_project /app/replace_with_project
EXPOSE 8080
ENV RUST_BACKTRACE=1
CMD ["./replace_with_project"]Postgres refuses to run as root, hence su postgres; the Debian package creates that user. Running the server as your app's runtime user is not an option either, because the builder stage runs as root.
A framework that does not typecheck queries at build time (Prisma, Django, Rails and the like) needs none of this: build as usual and migrate at boot.
Injected environment variables
| Variable | When | Value |
|---|---|---|
PORT | always | The port to listen on (currently 8080). |
DATABASE_URL | database projects | Postgres connection string, or sqlite:///data/db.sqlite. |
AUTH_ISSUER | always | Expected iss of platform JWTs. |
AUTH_AUDIENCE | always | Expected aud: game:<slug> in prod, game:dev-<slug> in dev. |
AUTH_JWKS_URL | always | JWKS endpoint for verifying JWTs. |
GBANDIT_ENVIRONMENT | always | dev or prod: which environment this is. |
Plus your own variables, set per environment with gbandit env set KEY=VALUE [-e prod]. They reach the backend, not the build, and a change takes effect the next time the backend starts: on the next deploy (dev) or promote (prod), or when it wakes from sleep. See Authentication for using the AUTH_* values.
Git behaviour
By default (local_dev.auto_commit: false) deploy never touches git: no commits, no pushes. A dirty tree deploys as it is.
With "auto_commit": true, every deploy auto-commits a dirty tree and, once the deploy has succeeded, pushes it to origin main. A deploy that fails takes its commit back off again, leaving your changes uncommitted in the working tree, so the history only holds commits that deployed. When the outcome isn't known (--detach, or the connection to the platform was lost) the commit stays local and the next successful deploy pushes it. A rejected push doesn't fail an already successful deploy; pull and push yourself. Enable it for projects whose repo is dedicated to gbandit; leave it off when deploying from a pre-existing repo you don't want touched. --message "what changed" labels the deploy in the history and becomes the commit message. The Pi Agent always deploys this way, whatever gbandit.jsonc says.
The commit is by your git identity. Without one configured, it is by your gbandit name and a @users.noreply.gbandit.com address.
Publishing to the catalog
A game is not on games.gbandit.com until you publish it, and publishing needs two things: a cover image, and a game promoted to prod. The catalog always shows what prod runs, so promoting a new version updates a published game without publishing it again.
sh
gbandit project cover cover.png # PNG, JPEG or WebP, cropped to 16:9
gbandit project publish # list the game in the catalog
gbandit project unpublish # take it out again; the game keeps running
gbandit project show # title, cover, catalog status and play URL
gbandit project show --json # the same, machine-readableAll of them take --project <slug> to act on a project other than the one in gbandit.jsonc. The title comes from title in gbandit.jsonc, or from the project's Settings page when the file has none. The Settings page can also upload the cover and publish.
Deploying from a script or agent
sh
gbandit deploy --create --json # create project if needed, machine-readable output
gbandit deploy --create --json --detach # return immediately with the pipeline run id--createcreates the project on first deploy without prompting.--jsonemits stable machine-readable JSON on stdout.--detachreturns after the pipeline run is created instead of waiting.--confirm-database-removalconfirms a deploy that removes a provisioned database (see Removing a database).
Promoting takes --json, --detach, --project and --confirm-database-removal, and nothing else, since there is no source to upload, no project to create and no target to choose:
sh
gbandit promote --json # machine-readable; reports the commit prod
# runs and the one it moves to
gbandit promote --json --detach # return immediately with the pipeline run idA promotion never asks for confirmation, with one exception: pass --confirm-database-removal when the release dev runs no longer declares prod's database.
Print this documentation from the CLI at any time:
sh
gbandit docs # index of pages
gbandit docs deploy # this page, raw markdown
gbandit docs --full # all documentation in one output