Happier Docs
Deployment

Docker

Deploy the Happier Server using Docker / Docker Compose.

This page explains how to deploy your own Happier Server with Docker. It focuses on the server only.

Use /apps/server/.env.example as your base env template and load the same values in Docker/Compose.

We publish preview Docker images on Docker Hub (recommended):

docker pull happierdev/relay-server:preview

We also publish the same images to GitHub Container Registry (GHCR):

docker pull ghcr.io/happier-dev/relay-server:preview

Published images:

  • relay-server: Happier Server + embedded web UI (this page)
  • dev-box: Happier CLI + daemon in Docker (see: Dev box)

Tags:

  • :preview (floating preview tag)
  • :preview-<short-sha> (immutable preview build)
  • :stable / :latest (stable channel, when enabled)

The relay-server image defaults to the light flavor + SQLite, with all data stored under /data. Mount it as a volume so upgrades and restarts are safe.

docker run --rm -p 3005:3005 \
  -v happier-server-data:/data \
  happierdev/relay-server:preview

Configuration docs:

Notes:

  • This Docker image runs the Happier Server directly (a self-host-friendly default config).
  • It embeds the web UI bundle and serves it at / by default.
    • Disable UI serving: set HAPPIER_SERVER_UI_DIR= (empty).
    • Serve UI under /ui: set HAPPIER_SERVER_UI_PREFIX=/ui.
  • It does not install a managed host service. Upgrades happen when the container is restarted after pulling a new image tag.

Prebuilt image: use Postgres (full flavor)

The relay-server image is built from the same server code and can run full flavor too — you just override the defaults.

Example docker-compose.yml (Postgres + local files backend):

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: happier
      POSTGRES_USER: happier
      POSTGRES_PASSWORD: change-me
    volumes:
      - pgdata:/var/lib/postgresql/data

  relay:
    image: happierdev/relay-server:preview
    ports:
      - "3005:3005"
    environment:
      PORT: "3005"
      HAPPIER_SERVER_FLAVOR: full
      HAPPIER_DB_PROVIDER: postgres
      DATABASE_URL: postgresql://happier:change-me@db:5432/happier
      HANDY_MASTER_SECRET: change-me-to-a-long-random-string
      HAPPIER_FILES_BACKEND: local
      HAPPIER_SERVER_LIGHT_DATA_DIR: /data/happier
    volumes:
      - happier-relay-data:/data/happier
    depends_on:
      - db

volumes:
  pgdata:
  happier-relay-data:

For S3/Minio instead of local files, set HAPPIER_FILES_BACKEND=s3 and the S3_* env vars (see Environment variables).

Optional: expose a relay securely with Tailscale

If you want to access your self-hosted relay from a phone or remote machine without opening inbound ports, we recommend using Tailscale as a sidecar and publishing the server via Tailscale Serve.

This approach:

  • avoids public ingress and TLS setup
  • gives you a stable HTTPS URL inside your tailnet
  • keeps the Happier server image unprivileged (Tailscale runs in its own container)

Minimal docker-compose.yml example (preview channel):

services:
  relay:
    image: happierdev/relay-server:preview
    environment:
      PORT: "3005"
      # Optional:
      # Disable UI serving:
      # HAPPIER_SERVER_UI_DIR: ""
    volumes:
      - happier-relay-data:/data

  tailscale:
    image: tailscale/tailscale:stable
    network_mode: service:relay
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    devices:
      - /dev/net/tun:/dev/net/tun
    environment:
      TS_AUTHKEY: ${TS_AUTHKEY}
      TS_HOSTNAME: happier-relay
      TS_STATE_DIR: /var/lib/tailscale
    volumes:
      - tailscale-state:/var/lib/tailscale
    command: >
      sh -lc '
        tailscaled --state=${TS_STATE_DIR}/tailscaled.state &
        sleep 2
        tailscale up --authkey="${TS_AUTHKEY}" --hostname="${TS_HOSTNAME}" --accept-dns=false
        tailscale serve --bg --https=443 http://127.0.0.1:3005
        tail -f /dev/null
      '

volumes:
  happier-relay-data:
  tailscale-state:

Notes:

  • Use an ephemeral, one-time pre-auth key for TS_AUTHKEY when possible.
  • tailscale serve publishes the Happier UI + API (including WebSockets) behind the Tailscale HTTPS endpoint.
  • If your environment can’t use /dev/net/tun (some restricted hosts), fall back to a normal reverse proxy (Caddy/Traefik/nginx) or run Tailscale on the host OS and proxy to the container.

Build images

This repo includes a top-level Dockerfile with multiple targets.

# Server image (defaults to the full flavor; role defaults to "all" unless SERVER_ROLE is set at runtime)
docker build -t happier-server --target server .

# Worker image (sets SERVER_ROLE=worker in the image)
docker build -t happier-server-worker --target server-worker .

# Relay server image (defaults to light flavor + sqlite; stores state under /data)
docker build -t happier-relay-server --target relay-server .

Optional: build only selected DB providers

By default, the server image includes generated Prisma clients for all supported DB providers.

If you want a smaller, provider-specific image, you can limit which Prisma clients are generated at build time:

# Build with only Postgres (includes pglite support since it uses the Postgres client)
docker build -t happier-server --target server \
  --build-arg HAPPIER_BUILD_DB_PROVIDERS='postgres' \
  .

# Build with Postgres + MySQL (no SQLite client)
docker build -t happier-server --target server \
  --build-arg HAPPIER_BUILD_DB_PROVIDERS='postgres|mysql' \
  .

Notes:

  • pglite uses the Postgres Prisma client; include postgres (or pglite) to run the light flavor default.
  • If you build without mysql (or sqlite) and later run with HAPPIER_DB_PROVIDER=mysql (or sqlite), the server will fail at startup with a clear error telling you to rebuild with the missing provider included.

Full vs light flavor

Happier Server supports two “flavors” that share the same API + internal logic. The difference is which storage backends are used.

Use this if you want a production setup: an external database, and optional Redis for multi-replica Socket.IO. By default, full flavor uses S3/Minio for public files, but you can opt into local disk storage with HAPPIER_FILES_BACKEND=local.

You will need:

  • A database (DATABASE_URL)
  • Public files backend (choose one):
    • S3/Minio-compatible storage (the S3_* env vars) with HAPPIER_FILES_BACKEND=s3 (default)
    • Local disk with HAPPIER_FILES_BACKEND=local (stores files under HAPPIER_SERVER_LIGHT_DATA_DIR)
  • A stable secret (HANDY_MASTER_SECRET)
  • Optional: Redis for scaling realtime across multiple API replicas (REDIS_URL + HAPPIER_SOCKET_ADAPTER=redis-streams)

Light flavor (single-node / testing)

Use this if you want the simplest self-hosted setup: a single-node DB stored on disk (embedded Postgres via PGlite by default, or SQLite) and local file storage served by the server at GET /files/*.

You will need:

  • Persistent disk for the light data directory (DB + files + persisted secrets)
  • Optional HANDY_MASTER_SECRET for a stable secret (otherwise one is generated and persisted)

Database providers

The DB provider is selected via:

  • HAPPIER_DB_PROVIDER (preferred) or HAPPY_DB_PROVIDER

Supported values:

  • Full flavor: postgres (default), mysql (MySQL 8+)
  • Light flavor: pglite (default), sqlite

Quick start (single container)

This is useful for quick experiments. For production, prefer Docker Compose (next section) or set a restart policy and use an env file.

Full flavor (Postgres)

docker run --rm -p 3005:3005 \
  -e PORT=3005 \
  -e DATABASE_URL='postgresql://user:pass@db:5432/happier?sslmode=require' \
  -e HANDY_MASTER_SECRET='change-me-to-a-long-random-string' \
  -e HAPPIER_FILES_BACKEND=s3 \
  -e S3_HOST='minio' \
  -e S3_PORT='9000' \
  -e S3_USE_SSL='false' \
  -e S3_BUCKET='happier-public' \
  -e S3_PUBLIC_URL='https://files.example.com/happier-public' \
  -e S3_ACCESS_KEY='...' \
  -e S3_SECRET_KEY='...' \
  happier-server

Full flavor (MySQL 8+)

docker run --rm -p 3005:3005 \
  -e PORT=3005 \
  -e HAPPIER_DB_PROVIDER=mysql \
  -e DATABASE_URL='mysql://user:pass@db:3306/happier' \
  -e HANDY_MASTER_SECRET='change-me-to-a-long-random-string' \
  -e HAPPIER_FILES_BACKEND=s3 \
  -e S3_HOST='minio' \
  -e S3_PORT='9000' \
  -e S3_USE_SSL='false' \
  -e S3_BUCKET='happier-public' \
  -e S3_PUBLIC_URL='https://files.example.com/happier-public' \
  -e S3_ACCESS_KEY='...' \
  -e S3_SECRET_KEY='...' \
  happier-server

Production note: remove --rm and add --restart unless-stopped (or your platform’s equivalent), and prefer --env-file over long -e ... lists.

Example:

cp apps/server/.env.example .env
docker run --env-file .env -p 3005:3005 happier-server

Full flavor (local files backend)

If you don’t want S3/Minio, you can store public files on local disk:

docker run --rm -p 3005:3005 \
  -e PORT=3005 \
  -e DATABASE_URL='postgresql://user:pass@db:5432/happier?sslmode=require' \
  -e HANDY_MASTER_SECRET='change-me-to-a-long-random-string' \
  -e HAPPIER_FILES_BACKEND=local \
  -e HAPPIER_SERVER_LIGHT_DATA_DIR=/data/happier \
  -v happier-server-data:/data/happier \
  happier-server

Light flavor (PGlite, default)

The default server image starts the full flavor. To run light in Docker, you can either:

  • use the relay-server target (recommended for self-host), or
  • run the server target with HAPPIER_SERVER_FLAVOR=light.

For PGlite specifically, you still need to run the light migrations helper script (PGlite’s connection string is created at runtime).

docker run --rm -p 3005:3005 \
  -e PORT=3005 \
  -e HAPPIER_SERVER_LIGHT_DATA_DIR=/data/server-light \
  -v happier-server-light:/data/server-light \
  happier-server \
  sh -lc 'yarn --cwd apps/server migrate:light:deploy && yarn --cwd apps/server start:light'

Light flavor (SQLite)

SQLite is the recommended light-mode database for Docker because it is single-process and easy to back up. The server image entrypoint automatically runs prisma migrate deploy for SQLite when RUN_MIGRATIONS=1 (default).

docker run --rm -p 3005:3005 \
  -e PORT=3005 \
  -e HAPPIER_SERVER_FLAVOR=light \
  -e HAPPIER_DB_PROVIDER=sqlite \
  -e HAPPIER_SERVER_LIGHT_DATA_DIR=/data/server-light \
  -v happier-server-light:/data/server-light \
  happier-server

For most self-hosters, Docker Compose is the most reliable and reproducible way to run the server: it keeps env vars and volumes in one place and makes upgrades easier.

You can either:

  • copy values from /apps/server/.env.example into environment:, or
  • use env_file: with a managed copy of that file.

Full flavor (single process)

This example uses Postgres + Minio. Redis is optional and only needed for multi-replica API deployments.

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: happier
      POSTGRES_USER: happier
      POSTGRES_PASSWORD: change-me
    volumes:
      - pgdata:/var/lib/postgresql/data

  minio:
    image: minio/minio
    command: server /data --console-address :9001
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    volumes:
      - minio:/data
    ports:
      - "9000:9000"
      - "9001:9001"

  server:
    build:
      context: .
      target: server
    ports:
      - "3005:3005"
    environment:
      PORT: "3005"
      DATABASE_URL: postgresql://happier:change-me@db:5432/happier
      HANDY_MASTER_SECRET: change-me-to-a-long-random-string
      HAPPIER_FILES_BACKEND: s3
      S3_HOST: minio
      S3_PORT: "9000"
      S3_USE_SSL: "false"
      S3_BUCKET: happier-public
      # For Minio behind a reverse proxy, set this to your public files URL.
      S3_PUBLIC_URL: https://files.example.com/happier-public
      S3_ACCESS_KEY: minioadmin
      S3_SECRET_KEY: minioadmin
      # Single-container mode: leave SERVER_ROLE unset (defaults to "all")
    depends_on:
      - db
      - minio

volumes:
  pgdata:
  minio:

Full flavor (single process, MySQL 8+)

This example uses MySQL 8+ + Minio.

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: happier
      MYSQL_ROOT_PASSWORD: change-me
    volumes:
      - mysqldata:/var/lib/mysql

  minio:
    image: minio/minio
    command: server /data --console-address :9001
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    volumes:
      - minio:/data
    ports:
      - "9000:9000"
      - "9001:9001"

  server:
    build:
      context: .
      target: server
    ports:
      - "3005:3005"
    environment:
      PORT: "3005"
      HAPPIER_DB_PROVIDER: mysql
      DATABASE_URL: mysql://root:change-me@db:3306/happier
      HANDY_MASTER_SECRET: change-me-to-a-long-random-string
      HAPPIER_FILES_BACKEND: s3
      S3_HOST: minio
      S3_PORT: "9000"
      S3_USE_SSL: "false"
      S3_BUCKET: happier-public
      S3_PUBLIC_URL: https://files.example.com/happier-public
      S3_ACCESS_KEY: minioadmin
      S3_SECRET_KEY: minioadmin
    depends_on:
      - db
      - minio

volumes:
  mysqldata:
  minio:

Light flavor (Compose, PGlite or SQLite)

The light flavor runs a single process and stores everything on disk.

services:
  server:
    build:
      context: .
      target: server
    ports:
      - "3005:3005"
    environment:
      PORT: "3005"
      HAPPIER_SERVER_LIGHT_DATA_DIR: /data/server-light
      # default: embedded Postgres via PGlite
      # HAPPIER_DB_PROVIDER: pglite
      # alternative:
      # HAPPIER_DB_PROVIDER: sqlite
    volumes:
      - happier-server-light:/data/server-light
    command: >
      sh -lc '
        if [ "${HAPPIER_DB_PROVIDER:-pglite}" = "sqlite" ]; then
          yarn --cwd apps/server migrate:sqlite:deploy &&
          HAPPIER_DB_PROVIDER=sqlite yarn --cwd apps/server start:light;
        else
          yarn --cwd apps/server migrate:light:deploy &&
          yarn --cwd apps/server start:light;
        fi
      '

volumes:
  happier-server-light:

Full flavor (API + worker)

If you split roles, run:

  • API container(s) with SERVER_ROLE=api
  • Worker container with SERVER_ROLE=worker

With more than one API replica, enable Redis fanout (REDIS_URL + HAPPIER_SOCKET_ADAPTER=redis-streams) and configure sticky sessions at your load balancer.

Minimal example (add this on top of the full-flavor compose above):

services:
  redis:
    image: redis:7

  api:
    build:
      context: .
      target: server
    environment:
      SERVER_ROLE: api
      REDIS_URL: redis://redis:6379
      HAPPIER_SOCKET_ADAPTER: redis-streams
    depends_on:
      - db
      - minio
      - redis

  worker:
    build:
      context: .
      target: server-worker
    environment:
      REDIS_URL: redis://redis:6379
      HAPPIER_SOCKET_ADAPTER: redis-streams
    depends_on:
      - db
      - minio
      - redis

Migrations

The server Docker target runs DB migrations by default before starting:

  • Full flavor:
    • Provider is selected by HAPPIER_DB_PROVIDER (default postgres).
    • The container runs prisma migrate deploy against the matching schema on startup.
  • Light flavor:
    • The default image entrypoint starts full flavor, so for light you run migrations explicitly in your overridden command (examples above).

Notes:

  • Disable auto-migrations in full flavor with RUN_MIGRATIONS=0.
  • In Postgres multi-replica setups, it’s OK if more than one replica tries to migrate at startup (the DB serializes via locks).

Reverse proxy checklist

  • Terminate TLS at the proxy (recommended).
  • Forward WebSocket upgrade headers.
  • Increase timeouts for long-lived connections.
  • Keep secrets in your deployment platform (env vars), not in git.

Development notes (contributors)

If you are developing Happier Server itself (not just deploying it), keep in mind:

  • There are two runtime flavors: full (yarn --cwd apps/server start) and light (yarn --cwd apps/server start:light).
  • The DB provider is selected via HAPPIER_DB_PROVIDER:
    • Full: postgres (default) or mysql
    • Light: pglite (default) or sqlite
  • Prisma model source of truth: apps/server/prisma/schema.prisma.
  • Provider schemas are generated from it:
    • yarn --cwd apps/server schema:sync (and tests enforce schema:sync:check)
  • Migrations are provider-specific:
    • Postgres/PGlite: apps/server/prisma/migrations/*
    • SQLite: apps/server/prisma/sqlite/migrations/*
    • MySQL: apps/server/prisma/mysql/migrations/*

When you change the data model, make sure you:

  1. Update apps/server/prisma/schema.prisma
  2. Run yarn --cwd apps/server schema:sync
  3. Create migrations for each supported provider (at least Postgres + MySQL; and SQLite if you want light/sqlite supported for that change)

On this page