The fastest way to get a working Thunderbolt on one machine. One command brings up five containers, and there is no cluster or cloud account involved. Each service runs as a single copy with no redundancy, so this is not a highly available deployment.
git clone https://github.com/thunderbird/thunderbolt.git
cd thunderbolt/deploy
cp .env.example .envOpen deploy/.env and set BETTER_AUTH_SECRET; Compose refuses to start while it is empty. Generate one with:
openssl rand -base64 32Then bring the stack up:
docker compose up --buildThe first run builds the app and API images, pulls the other three, starts PostgreSQL, imports the Keycloak sign-in configuration, and applies database migrations before the API accepts traffic. Later runs start in seconds.
| What | Where | Credentials |
|---|---|---|
| The app | http://localhost:3000 |
demo@thunderbolt.io / demo |
| Keycloak admin | http://localhost:8180 |
admin / admin |
Both sets of credentials are published in this repository. So are four more that deploy/.env cannot override, because they live in deploy/docker-compose.yml: the OIDC client secret, the POWERSYNC_JWT_SECRET and PS_JWT_KEY_BASE64 pair the sync service verifies against, the PostgreSQL superuser password, and the powersync_role replication password, which appears in three places in that file. PostgreSQL is published on a host port, so its password is a live credential rather than an internal one.
Before anyone outside your machine can reach the deployment, create your own user, rotate the Keycloak admin password, and rotate all four compose-file credentials, changing both halves of the JWT pair together and all three copies of the replication password.
| Container | Port | What it does |
|---|---|---|
frontend |
3000 |
Serves the app and forwards API calls to the backend. This is the address users open. |
backend |
8000 |
The API. Sign-in, sync tokens, and outbound calls to AI providers. |
postgres |
5434 |
Accounts, sessions, and the server-side copy of synced data. |
powersync |
8081 |
Streams data changes to every signed-in device. |
keycloak |
8180 |
The bundled identity provider, preloaded with a sign-in configuration and the demo user. |
Users open port 3000, but their browser also talks to ports 8180 and 8081 directly: sign-in redirects to Keycloak, and the app opens its own connection to the sync service. All three must be reachable from wherever the browser runs. Port 8000 is published for convenience only, since the app reaches the API through port 3000.
To change a port, edit FRONTEND_PORT, BACKEND_PORT, POSTGRES_PORT, POWERSYNC_PORT or KEYCLOAK_PORT in deploy/.env and restart. The rest of the stack follows automatically.
Data lives in a single Docker volume that docker volume ls lists as deploy_pg_data, and nothing is written outside Docker.
Users can add their own provider keys in the app, so the stack is usable without any server-side key. To make a provider available to everyone, set its key in deploy/.env and restart:
ANTHROPIC_API_KEY=
FIREWORKS_API_KEY=
EXA_API_KEY=EXA_API_KEY enables web search rather than a chat model, and FIREWORKS_API_KEY only serves the hosted agent when AGENT_MODEL names a Fireworks model. The Configuration page lists every provider setting.
To show your own name and colors, set BRANDING in deploy/.env and restart. Keep the JSON on one line and wrap it in single quotes, or the # of a hex color starts a comment:
BRANDING='{"appName":"Acme AI","debugTranscriptRecipient":"Acme IT","cssVars":{"light":{"primary":"#4f46e5"}}}'Branding lists every field. debugTranscriptRecipient names your team in the copy users see when they share a debug transcript, and defaults to Thunderbolt.
docker compose ps # every service up, postgres and keycloak healthy
curl http://localhost:3000/v1/config # returns JSON, no sign-in needed
docker compose logs -f backend # "Running database migrations", then "Starting server"The curl goes through the app's own address, so a JSON reply means the browser-facing service and the API behind it are both answering. Sign in as the demo user and open the same conversation in a second browser window: if it appears there too, sync is working as well.
It assumes localhost. Sign-in URLs, the API origin and the sync address are all written as localhost addresses. Serving this stack to other machines under a real hostname means editing those addresses in deploy/docker-compose.yml, not only the port settings in deploy/.env. For a shared deployment we recommend Kubernetes or AWS instead.
There is no TLS. Everything is plain HTTP. Don't let this stack leave your machine without a reverse proxy such as Caddy, nginx or Traefik in front of it. Browsers grant the local-database and isolation capabilities the app depends on only to secure origins, which means localhost or HTTPS and nothing in between, so on a plain-HTTP hostname the local database falls back to memory and every reload loses the user's data.
Rate limiting is off and Keycloak runs in its development mode. Don't put this stack in front of real users.
PostgreSQL 17 is pinned. The bundled database is version 17 and its data volume is mounted at the path version 17 expects. Don't swap the image for 18 or later: PostgreSQL 18 moved that path, so it refuses to start against an existing volume.
Both swaps below mean editing deploy/docker-compose.yml directly. The settings involved are written into that file rather than read from deploy/.env, so changing them in .env alone has no effect.
Your own identity provider. Remove the keycloak service, and the backend's dependency on it, from deploy/docker-compose.yml. Then replace the bundled OIDC values in the backend's settings with your own. For OpenID Connect (OIDC) set AUTH_MODE to oidc plus OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET; for SAML set AUTH_MODE to saml plus SAML_ENTRY_POINT, SAML_ENTITY_ID, SAML_IDP_ISSUER and SAML_CERT. Add your provider's origin to TRUSTED_ORIGINS. The Configuration page documents each of these.
Managed PostgreSQL. Point DATABASE_URL and the sync service's two connection strings at it, then remove the postgres service. The bundled database is prepared for replication on first boot and a managed one is not, so before switching you must set it up by hand: enable logical replication (wal_level=logical), create a role named powersync_role with the REPLICATION and BYPASSRLS attributes, create a publication named powersync covering all tables, and create a second database named powersync_storage for the sync service's own bookkeeping. deploy/docker/postgres-init/01-powersync.sh is the script that does all of this on the bundled database; run it against the managed one by hand. Don't put powersync_storage on managed PostgreSQL 17. The self-hosting overview has the detail.
cd thunderbolt
git pull
cd deploy
docker compose pull # refreshes PostgreSQL, the sync service and Keycloak
docker compose up -d --buildDatabase migrations run automatically when the API starts, sync configuration is re-read on restart, and your data is preserved. See Upgrading for version-specific notes.
docker compose down # stop the containers, keep all data
docker compose down -v # also delete the database volume, losing everything
down -verases every account, conversation and setting on the server. Take a backup first.
- Configuration: every setting and environment variable, with defaults.
- Backup and restore: what to copy, and how to bring it back.
- Monitoring: health endpoints, logs, and metrics.
