Skip to content

Deployment with Workspaces

Everything in the core deployment, plus workspaces: shared storage for your team's documents, sign-in through your organization's identity provider, and live sessions that only people who have signed in can join.

Documents are encrypted in the browser before they reach your server, so the store holds nothing it can read. How your work is protected explains what that means for your users.

You'll end up with

https://design.example.gov/          the editor
https://design.example.gov/docs/     this documentation
wss://design.example.gov/relay/      the relay, for live sessions
https://design.example.gov/store/    the workspace store
https://design.example.gov/auth/     Keycloak - only if you run it here

Plan for about thirty minutes.

1. Before you start

Everything the core deployment needs - Docker with Compose v2, a DNS name, a TLS certificate, ports 80 and 443 - plus somewhere for people to sign in. Either:

  • Your organization's identity provider - Keycloak, Entra ID, Okta, Auth0, GitLab or Google; anything that speaks OpenID Connect. Recommended for production: people sign in with the accounts they already have.
  • Keycloak, run here alongside everything else. Good when you have no provider, or for evaluating. You then manage its users yourself.

Decide now; step 5 differs between them.

2. Create a folder with the files

bash
mkdir system-design && cd system-design
mkdir certs keys
yaml
# System Design Editor - with workspaces: shared, encrypted team storage.
#
# One domain, one TLS proxy:
#   https://<DOMAIN>/         the editor (and its documentation at /docs/)
#   wss://<DOMAIN>/relay/     the relay, for live sessions
#   https://<DOMAIN>/store/   the workspace store
#
# The store holds documents it cannot read: they are encrypted in the
# browser. Sign-in uses your identity provider; to run one here instead,
# add compose.keycloak.yml (see the setup guide).

name: system-design

services:
  editor:
    image: ghcr.io/jbraunsmajr/system-design:${VERSION:-latest}
    restart: unless-stopped
    environment:
      APP_URL: https://${DOMAIN:?Set DOMAIN in .env}/
      RELAY: wss://${DOMAIN}/relay/
      STORE_URL: https://${DOMAIN}/store
      ICE_SERVERS: ${ICE_SERVERS:-}

  relay:
    image: ghcr.io/jbraunsmajr/system-design-relay:${VERSION:-latest}
    restart: unless-stopped
    environment:
      # The same value as the store's: sessions then require signing in.
      RELAY_TOKEN_SECRET: ${RELAY_TOKEN_SECRET:?Set RELAY_TOKEN_SECRET in .env}

  store:
    image: ghcr.io/jbraunsmajr/system-design-store:${VERSION:-latest}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      PUBLIC_URL: https://${DOMAIN}/store
      AFTER_LOGIN_URL: https://${DOMAIN}/
      DATABASE_URL: postgresql://store:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@postgres:5432/store
      AUTH_PROVIDERS: oidc
      OIDC_ISSUER: ${OIDC_ISSUER:?Set OIDC_ISSUER in .env}
      OIDC_INTERNAL_URL: ${OIDC_INTERNAL_URL:-}
      OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?Set OIDC_CLIENT_ID in .env}
      OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?Set OIDC_CLIENT_SECRET in .env}
      RECOVERY_PUBLIC_KEY_FILE: /run/keys/recovery-public.pem
      RELAY_TOKEN_SECRET: ${RELAY_TOKEN_SECRET}
      ADMIN_SUBJECTS: ${ADMIN_SUBJECTS:-}
      RETENTION_PERIOD: ${RETENTION_PERIOD:-30d}
      # If you are using self-signed certs, mount your certificates and specify the CA here
      # NODE_EXTRA_CA_CERTS: /certs/your-ca.crt
    volumes:
      # The PUBLIC half only. The private half never belongs on this host.
      - ./keys/recovery-public.pem:/run/keys/recovery-public.pem:ro
      # If you are using self-signed certs, mount the certs
      # - ./certs:/certs:ro

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: store
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: store
    volumes:
      - store-data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U store']
      interval: 5s
      timeout: 5s
      retries: 10

  proxy:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - editor
      - relay
      - store

volumes:
  store-data:
nginx
# TLS termination for the System Design Editor, with workspaces.
# Mounted into the proxy container as /etc/nginx/conf.d/default.conf.

# WebSocket upgrades for the relay.
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# Plain HTTP only redirects.
server {
    listen 80;
    server_name _;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name _;

    # Your certificate and key, in ./certs beside this file.
    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    # The relay. The trailing slashes strip /relay/ before it reaches the
    # relay, so /relay/health is the relay's /health.
    location /relay/ {
        proxy_pass http://relay:4444/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        # Sessions stay open while people work; don't cut them off.
        proxy_read_timeout 1h;
        proxy_send_timeout 1h;
    }

    # The workspace store. /store/v1/... reaches the store as /v1/....
    location /store/ {
        proxy_pass http://store:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        # Documents are uploaded as encrypted blobs of up to 8 MB.
        client_max_body_size 16m;
    }

    # Keycloak, only if you run it here (compose.keycloak.yml). Resolved per
    # request, so this file works unchanged when Keycloak is not running -
    # nginx would otherwise refuse to start for want of the "keycloak" host.
    location /auth/ {
        resolver 127.0.0.11 valid=30s;
        set $keycloak http://keycloak:8080;
        proxy_pass $keycloak;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_buffer_size 16k;
        proxy_buffers 8 16k;
    }

    # The editor, and its documentation at /docs/.
    location / {
        proxy_pass http://editor:80;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
ini
# ── Where ──────────────────────────────────────────────────────────────
# The name people will type to reach the editor. Point its DNS at this host.
DOMAIN=design.example.gov

# "latest" follows each release; a date such as 2026-09-21 pins one.
VERSION=latest

# ── Secrets ────────────────────────────────────────────────────────────
# Generate each with:   openssl rand -hex 32
POSTGRES_PASSWORD=
RELAY_TOKEN_SECRET=
OIDC_CLIENT_SECRET=

# ── Sign-in ────────────────────────────────────────────────────────────
# Option A - your organization's provider (Keycloak, Entra ID, Okta, ...).
# The issuer URL is the one that serves /.well-known/openid-configuration.
OIDC_ISSUER=https://login.example.gov/realms/yourrealm
OIDC_CLIENT_ID=system-design-store

# This is meant to be the docker network based route, bypass the domain and such if needed
OIDC_INTERNAL_URL=

# Option B - Keycloak run here (compose.keycloak.yml). Replace the three
# lines above with these, and set the two passwords.
# OIDC_ISSUER=https://design.example.gov/auth/realms/system-design
# OIDC_CLIENT_ID=system-design-store
# OIDC_INTERNAL_URL=http://keycloak:8080
# KEYCLOAK_ADMIN_PASSWORD=
# KEYCLOAK_DB_PASSWORD=

# ── After your first sign-in ───────────────────────────────────────────
# issuer#subject for each administrator (see step 9 of the guide).
ADMIN_SUBJECTS=

# How long deleted documents stay restorable: immediate, 30d, 6m, 7y,
# or indefinite. Set this to your records schedule before the first
# document is stored.
RETENTION_PERIOD=30d

# Only for networks where browsers cannot reach each other directly.
ICE_SERVERS=

If you will run Keycloak here, also create these two:

yaml
# Optional: run Keycloak here as the identity provider, at https://<DOMAIN>/auth/.
# Use it with:  docker compose -f compose.yml -f compose.keycloak.yml up -d
#
# Skip this file entirely if your organization already has a provider.

services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    restart: unless-stopped
    command: start --import-realm
    depends_on:
      keycloak-db:
        condition: service_healthy
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set KEYCLOAK_ADMIN_PASSWORD in .env}
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?Set KEYCLOAK_DB_PASSWORD in .env}
      # Served behind the proxy at /auth/, and told so, so the addresses it
      # hands out are the ones browsers can reach.
      KC_HOSTNAME: ${DOMAIN}

      # These notes are assuming keycloak is hosted on the same machine as everything else.
      # This value will depend on the reverse proxy configuration.
      # For instance, if this is hosted on `example.com` your proxy might take `/auth` url and forward it to keycloak.
      # example.com/auth.
      # It is NOT recommended to strip the path from the URL at the proxy
      KC_HTTP_RELATIVE_PATH: /auth
      KC_HTTP_ENABLED: 'true'
      KC_PROXY_HEADERS: xforwarded
      KC_HEALTH_ENABLED: 'true'
      # Read by keycloak-realm.json when it is imported.
      DOMAIN: ${DOMAIN}
      OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
      # If you are using self-signed certificates, you can use the following to trust them.
      # KC_TRUSTSTORE_PATHS: /certs
    volumes:
      - ./keycloak-realm.json:/opt/keycloak/data/import/system-design-realm.json:ro
      # Mount self-signed certificates
      # - ./certs:/certs:ro

  keycloak-db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
      POSTGRES_DB: keycloak
    volumes:
      - keycloak-data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U keycloak']
      interval: 5s
      timeout: 5s
      retries: 10

  proxy:
    depends_on:
      - keycloak

volumes:
  keycloak-data:
json
{
  "realm": "system-design",
  "enabled": true,
  "sslRequired": "external",
  "registrationAllowed": false,
  "loginWithEmailAllowed": true,
  "clients": [
    {
      "clientId": "system-design-store",
      "name": "System Design workspace",
      "enabled": true,
      "protocol": "openid-connect",
      "publicClient": false,
      "secret": "${OIDC_CLIENT_SECRET}",
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": false,
      "serviceAccountsEnabled": false,
      "redirectUris": ["https://${DOMAIN}/store/v1/auth/callback"],
      "webOrigins": ["https://${DOMAIN}"],
      "attributes": { "pkce.code.challenge.method": "S256" }
    }
  ]
}

3. Set your domain and generate the secrets

In .env, set DOMAIN to your name. Then fill in the three secrets with long random values. This prints them ready to paste:

bash
for name in POSTGRES_PASSWORD RELAY_TOKEN_SECRET OIDC_CLIENT_SECRET; do
  echo "$name=$(openssl rand -hex 32)"
done

Keep .env private

It holds the database password and the secret that controls who can join a session. chmod 600 .env, and keep it out of version control.

4. Create the recovery key

The organization's recovery key is the last way back into a document if every person's own keys are lost. The store refuses documents until it has one.

bash
docker run --rm \
  -u $(id -u):$(id -g) \
  -v ./keys:/keys \
  ghcr.io/jbraunsmajr/system-design-store:latest \
  generate-recovery-key --out /keys/recovery

(On Windows PowerShell, use -v ${PWD}/keys:/keys and leave out -u.)

This creates two files in keys/:

FileWhat to do with it
recovery-public.pemLeave it where it is. The store reads it.
recovery-private.pemMove it off this server now, to somewhere offline - a password manager, an encrypted drive in a safe. Keep it apart from your database backups.

Nobody can recreate the private half

Without it, nothing escrowed to this key can ever be recovered - not by you, not by us. Losing it does not affect day-to-day use, but it removes the last route back for anyone who loses everything.

5. Connect sign-in

Option A - your organization's provider

Register the workspace with your provider as a confidential client:

SettingValue
Client IDsystem-design-store (or anything; match OIDC_CLIENT_ID. This example value comes from the keycloak-realm.json)
Client typeConfidential, with a client secret
Client secretThe OIDC_CLIENT_SECRET from your .env
Redirect URIhttps://design.example.gov/store/v1/auth/callback
FlowAuthorization Code, with PKCE
Scopesopenid profile

Then set OIDC_ISSUER in .env to your provider's issuer - the URL that serves /.well-known/openid-configuration. For Keycloak that is https://<keycloak>/realms/<realm>. Leave OIDC_INTERNAL_URL empty.

Check the issuer before going further

bash
curl https://login.example.gov/realms/yourrealm/.well-known/openid-configuration

If that doesn't return JSON from this server, the store won't reach it either.

Option B - Keycloak, run here

In .env, replace the three Option A lines with the Option B ones, and set KEYCLOAK_ADMIN_PASSWORD and KEYCLOAK_DB_PASSWORD (the loop from step 3 works for these too). Nothing else to edit: the realm file reads your domain and client secret when Keycloak imports it.

You'll add people to Keycloak in step 8.

6. Add your certificate

As in the core deployment: certs/fullchain.pem and certs/privkey.pem.

7. Start it

bash
docker compose up -d
bash
docker compose -f compose.yml -f compose.keycloak.yml up -d

With Keycloak, give it a minute on first start: it builds itself and imports the realm.

8. Check it works

bash
curl https://design.example.gov/store/v1/health

You should see:

json
{
  "status": "ok",
  "cryptoMode": "webcrypto",
  "authentication": "required",
  "escrow": "configured",
  "relayAuthentication": "required"
}
If you seeIt means
"escrow":"missing"keys/recovery-public.pem isn't there. Redo step 4.
"relayAuthentication":"none"RELAY_TOKEN_SECRET is empty in .env.
No responsedocker compose logs store - the store says what is wrong and stops, rather than starting misconfigured.

Option B only - add people. Open https://design.example.gov/auth/admin (Depends on your proxy configuration. Assuming everything is hosted at design.example.com and your proxy is set to forward /auth traffic to your keycloak instance), sign in as admin with KEYCLOAK_ADMIN_PASSWORD, switch to the system-design realm, and add a user under Users. Give each person an email address, marked verified: Keycloak will otherwise interrupt their first sign-in to ask for one.

9. Sign in, and make yourself an administrator

  1. Open https://design.example.gov, then File → Documents, and sign in.
  2. Choose Set up this browser. As the first person in, you create the workspace's keys.
  3. Choose Create a recovery code and keep it somewhere safe - it is yours, and different from the organization's recovery key in step 4.

To become an administrator - needed only for legal holds and purges - open https://design.example.gov/store/v1/auth/session and copy issuer and subject. Put them in .env, joined with #:

ini
ADMIN_SUBJECTS=https://login.example.gov/realms/yourrealm#9b1f3c52-6a1e-4c3a-9d2e-1f0a7b5c4e21

and restart the store: docker compose up -d store. (With Keycloak, add -f compose.yml -f compose.keycloak.yml as in step 7.)

10. Invite your team

Send people the address. When someone new signs in, everyone already in the workspace sees a notice over their document with Give access on it; one press lets them in. Workspaces is the guide to give them.

Keeping it running

TaskCommand
See what is runningdocker compose ps
Read the logsdocker compose logs -f store
Update to the newest releasedocker compose pull && docker compose up -d

With Keycloak, add -f compose.yml -f compose.keycloak.yml to each.

Backups

Back up the store-data volume - and keycloak-data if you run Keycloak - like any PostgreSQL database:

bash
docker compose exec postgres pg_dump -U store store > store-$(date +%F).sql

The store's database holds only ciphertext, so a backup is useless to anyone who steals it - and useless to you without the recovery key from step 4. Keep that key somewhere other than the backups.

Next

System Design Editor Documentation