Run the full stack with Docker¶
This guide explains how to run the entire backend (API, worker, Temporal, PostgreSQL, Keycloak) inside Docker while keeping the UI dev server on the host for hot-reload.
How it works¶
The project uses two Docker Compose files:
| File | Purpose |
|---|---|
docker-compose.yml |
Infrastructure: PostgreSQL, Temporal, Temporal UI, Keycloak |
docker-compose.local.yml |
Application services: api-service, worker-service |
Running both together with mise run local:up is equivalent to:
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
The application images are built from apps/api-service/Dockerfile and
apps/worker-service/Dockerfile. Each uses a two-stage build:
- Builder —
eclipse-temurin:21-jdk-alpine, runs./gradlew :apps/<service>:installDist - Runtime —
eclipse-temurin:21-jre-alpine, copies the distribution and runs it
A BuildKit layer cache (/root/.gradle) means subsequent builds only recompile changed modules.
Service ports¶
| Service | URL |
|---|---|
| REST API | http://localhost:8080 |
| Temporal gRPC | localhost:7233 |
| Temporal UI | http://localhost:8233 |
| Keycloak | http://localhost:8180 (admin: admin / admin) |
| PostgreSQL | localhost:5432 |
| Vite dev server | http://localhost:5173 |
Step-by-step¶
1. Start everything¶
mise run local:up
This builds the application images (first run takes a few minutes while Gradle downloads dependencies) and starts all seven containers.
2. Apply the DB schema (first time only)¶
Wait until PostgreSQL is healthy, then:
mise run migrate
3. Start the UI dev server¶
mise run ui:install # first time only
mise run ui:dev # http://localhost:5173
The Vite dev server proxies /api to localhost:8080, so the browser never makes
cross-origin requests.
Rebuild after code changes¶
mise run local:up # --build is always passed; only changed layers rebuild
Only the modules that changed will recompile. Infrastructure containers are unaffected.
Tear down¶
mise run local:down # stop and remove containers, keep volumes
mise run local:down -v # also remove volumes (wipes the database)
Mise task reference¶
| Task | Command |
|---|---|
mise run local:up |
Build images + start all services |
mise run local:down |
Stop all services |
mise run services:up |
Infrastructure only (no app images) |
mise run services:down |
Stop infrastructure only |
Keycloak realm¶
The realm is imported automatically from ui/keycloak/realm-export.json on first startup via
Keycloak's KC_IMPORT mechanism — no manual configuration needed.
The realm defines:
- Realm:
wrkflw - Client:
wrkflw-ui(public client, PKCE, redirect tohttp://localhost:5173/*) - Groups:
initiators,legal-reviewers,compliance-reviewers— these map directly to candidate groups in the workflow - Users: pre-seeded test accounts (passwords in the JSON below)
{
"realm": "wrkflw",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": false,
"accessTokenLifespan": 300,
"clients": [
{
"clientId": "wrkflw-ui",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"implicitFlowEnabled": false,
"redirectUris": [
"http://localhost:5173/*"
],
"webOrigins": [
"http://localhost:5173"
],
"attributes": {
"pkce.code.challenge.method": "S256"
},
"protocol": "openid-connect",
"protocolMappers": [
{
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": false,
"config": {
"full.path": "false",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "groups",
"userinfo.token.claim": "true"
}
}
]
}
],
"groups": [
{
"name": "initiators"
},
{
"name": "legal-reviewers"
}
],
"users": [
{
"username": "alice",
"enabled": true,
"email": "alice@wrkflw.local",
"firstName": "Alice",
"lastName": "Initiator",
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
],
"groups": [
"initiators"
]
},
{
"username": "bob",
"enabled": true,
"email": "bob@wrkflw.local",
"firstName": "Bob",
"lastName": "Reviewer",
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
],
"groups": [
"legal-reviewers"
]
},
{
"username": "carol",
"enabled": true,
"email": "carol@wrkflw.local",
"firstName": "Carol",
"lastName": "Both",
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
],
"groups": [
"initiators",
"legal-reviewers"
]
}
]
}