Skip to content

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:

  1. Buildereclipse-temurin:21-jdk-alpine, runs ./gradlew :apps/<service>:installDist
  2. Runtimeeclipse-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 to http://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"
      ]
    }
  ]
}