DocsDeploymentDocker Deployment

Docker Deployment

The easiest way to deploy Bulwark in production is with Docker. Pre-built images are published only to GitHub Container Registry (GHCR) at ghcr.io/bulwarkmail/webmail. Both linux/amd64 and linux/arm64 are built natively (no QEMU emulation) so ARM deployments run at full speed.

Two release channels are available as separate GHCR packages:

TagChannelSource
ghcr.io/bulwarkmail/webmail:latestStablemain branch tags
ghcr.io/bulwarkmail/webmail:devDevdev branch builds
ghcr.io/bulwarkmail/webmail:1.6.4PinnedSpecific release

First-Launch Setup Wizard

Since 1.6.4 you don't need to write .env.local before the first start - launch the container with persistent volumes for ADMIN_CONFIG_DIR and ADMIN_STATE_DIR, then open the URL and the web setup wizard takes you through JMAP, OAuth, branding, and admin password. The wizard persists everything to ADMIN_CONFIG_DIR/config.json. After setup the config volume can optionally be remounted read-only (drop a .config-locked marker from the wizard or set ADMIN_CONFIG_READONLY=true).

docker run -d --name bulwark \
  -p 3000:3000 \
  -v bulwark-config:/app/data/admin \
  -v bulwark-state:/app/data/admin-state \
  ghcr.io/bulwarkmail/webmail:latest
# Open http://localhost:3000 and follow the wizard

Setting JMAP_SERVER_URL in the environment skips the wizard - use that path when you want env-driven configuration.

Using Docker

Pull and Run

# Latest stable release
docker run -d \
  --name bulwark \
  -p 3000:3000 \
  -e JMAP_SERVER_URL=https://mail.example.com \
  ghcr.io/bulwarkmail/webmail:latest

# Pin to a specific version
docker run -d \
  --name bulwark \
  -p 3000:3000 \
  -e JMAP_SERVER_URL=https://mail.example.com \
  ghcr.io/bulwarkmail/webmail:1.5.2

# IPv6 dual-stack
docker run -d \
  --name bulwark \
  -p 3000:3000 \
  -e HOSTNAME=:: \
  -e JMAP_SERVER_URL=https://mail.example.com \
  ghcr.io/bulwarkmail/webmail:latest

Environment variables are read at runtime - no rebuild is needed when changing configuration.

Build from Source

git clone https://github.com/bulwarkmail/webmail.git
cd webmail
docker build -t bulwark .
docker run -d --name bulwark -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com bulwark

Docker Compose

Create a docker-compose.yml for running Bulwark alongside Stalwart:

services:
  stalwart:
    image: stalwartlabs/mail-server:latest
    container_name: stalwart
    ports:
      - "443:443"
      - "25:25"
      - "587:587"
      - "993:993"
      - "8080:8080"
    volumes:
      - stalwart-data:/opt/stalwart
    restart: unless-stopped

  bulwark:
    image: ghcr.io/bulwarkmail/webmail:latest
    container_name: bulwark
    ports:
      - "3000:3000"
    environment:
      JMAP_SERVER_URL: http://stalwart:8080
    depends_on:
      - stalwart
    healthcheck:
      test:
        [
          "CMD",
          "wget",
          "--no-verbose",
          "--tries=1",
          "--spider",
          "http://127.0.0.1:3000/api/health",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped

volumes:
  stalwart-data:

Alternatively, use an env_file to load settings from .env.local:

services:
  bulwark:
    image: ghcr.io/bulwarkmail/webmail:latest
    ports:
      - "3000:3000"
    env_file:
      - .env.local
    healthcheck:
      test:
        [
          "CMD",
          "wget",
          "--no-verbose",
          "--tries=1",
          "--spider",
          "http://127.0.0.1:3000/api/health",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped

Start the stack:

docker compose up -d

Persistent Volumes

Bulwark stores three kinds of state on disk. Mount persistent volumes for each if you want them to survive container restarts.

Settings sync (SETTINGS_DATA_DIR)

Encrypted per-account user preferences. Required only when SETTINGS_SYNC_ENABLED=true. Default: ./data/settings/app/data/settings in the container.

Admin config (ADMIN_CONFIG_DIR) - new in 1.6.4

Operator-authored state written by the setup wizard and the admin dashboard: config.json, policy.json, admin.json (passwordHash only), plugin-config/, plugins/, themes/, and uploaded branding assets. Default: ./data/admin/app/data/admin in the container.

After the setup wizard completes, this volume can optionally be mounted read-only for immutable deployments. Pair with ADMIN_CONFIG_READONLY=true so the app produces a clean error instead of an EROFS halfway through a write.

Admin runtime state (ADMIN_STATE_DIR) - new in 1.6.4

Runtime mutations that must always stay writable: admin-state.json (login timestamps), audit.log, and the bootstrap setup token. Default: ./data/admin-state/app/data/admin-state in the container.

Telemetry state (TELEMETRY_DATA_DIR)

Random instance_id, admin consent choice, HMAC'd login fingerprints. Default: ./data/telemetry/app/data/telemetry. Mount this so the consent and instance id survive image upgrades. Set BULWARK_TELEMETRY=off to disable the heartbeat entirely.

Legacy single-volume installs (ADMIN_DATA_DIR)

Pre-1.6.4 installs used a single ADMIN_DATA_DIR volume containing both config and state. That variable is still honoured when neither split variable is set - existing installs keep working without migration.

Example

bulwark:
  image: ghcr.io/bulwarkmail/webmail:latest
  environment:
    JMAP_SERVER_URL: http://stalwart:8080
    SESSION_SECRET: your-secret-key-here
    SETTINGS_SYNC_ENABLED: "true"
    ADMIN_PASSWORD: your-strong-admin-password
    EXTENSION_DIRECTORY_URL: https://extensions.bulwarkmail.org
  volumes:
    - bulwark-settings:/app/data/settings
    - bulwark-config:/app/data/admin # rw during setup
    # - bulwark-config:/app/data/admin:ro       # ro after setup
    - bulwark-state:/app/data/admin-state
    - bulwark-telemetry:/app/data/telemetry

Reverse Proxy

For production, place Bulwark behind a reverse proxy like Nginx or Caddy for TLS termination.

Caddy Example

mail.example.com {
    reverse_proxy bulwark:3000
}

Nginx Example

server {
    listen 443 ssl http2;
    server_name mail.example.com;

    ssl_certificate /etc/ssl/cert.pem;
    ssl_certificate_key /etc/ssl/key.pem;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Subpath Deployment

To serve Bulwark from a URL prefix like https://example.com/webmail, set NEXT_PUBLIC_BASE_PATH at build time (Next.js bakes it into asset URLs) and build your own image:

docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .

Then run with the matching locale prefix mode:

bulwark:
  image: bulwark-webmail
  environment:
    JMAP_SERVER_URL: http://stalwart:8080
    NEXT_PUBLIC_LOCALE_PREFIX: always

Do not strip the prefix at the proxy - the container expects requests under /webmail/... and serves all routes (/webmail/api/..., /webmail/_next/static/..., /webmail/sw.js, etc.) accordingly.

Health Check

Bulwark exposes a health check endpoint at /api/health. Use it in your Docker or orchestration health checks. The health endpoint includes detailed memory diagnostics and a stable liveness probe.