Self-hosting

Docker Compose

Set up DDM on one server with Docker Compose – from signing in to the registry to the first account.

On a single server, Docker Compose is the simplest way. Everything you need for it is on this page: three files and your deploy token.

1. Sign in to the registry

Sign the server in with the deploy token you got from us:

docker login registry.gitlab.com -u <token name> -p <Token>

2. Put the files in place

Create a folder, for example /opt/ddm, with these three files in it.

compose.yaml – the four services, their volumes and health checks. It stays as it is; what you set goes into .env.

compose.yaml
# DDM on one server, with Docker Compose — as the user guide describes it
# („Selbst betreiben" / „Self-hosting").
#
#     cp .env.example .env      # and fill it in
#     docker login registry.gitlab.com   # with the deploy token you were sent
#     docker compose up -d
#
# Four services: Postgres, the cms (the management app, and the API everything
# else talks to), the render worker (text, thumbnails and PDFs of what is
# uploaded) and the reader app. The cms and the reader app answer on two
# hostnames, behind a reverse proxy that terminates TLS: your own, pointed at
# the ports below, or Caddy from this file (`docker compose --profile proxy up
# -d`, see `Caddyfile`).
#
# Every setting of the user guide's „Konfiguration" / „Configuration" can go
# into `.env`; the ones set below are fixed by this layout. The images are those of
# the release in `DDM_VERSION`; the cms migrates the database when it starts.
name: ddm

x-app: &app
  restart: unless-stopped
  # Followed by Watchtower started with `--label-enable`, for an installation
  # that updates itself (`DDM_VERSION=stable`). Not Postgres: a new major of it
  # needs its data upgraded by hand.
  labels:
    com.centurylinklabs.watchtower.enable: "true"

services:
  postgres:
    # Postgres with the German dictionary DDM searches with — by its major,
    # which only ever changes by hand.
    image: ${DDM_REGISTRY:-registry.gitlab.com/iq-me/new-ddm-images}/postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: ddm
      POSTGRES_DB: ddm
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - postgres:/var/lib/postgresql
    healthcheck:
      test: [CMD, pg_isready, -U, ddm, -d, ddm]
      interval: 5s
      timeout: 5s
      retries: 10

  cms:
    <<: *app
    image: ${DDM_REGISTRY:-registry.gitlab.com/iq-me/new-ddm-images}/cms:${DDM_VERSION:-latest}
    env_file: .env
    environment:
      # Which version of these files this is: a release that needs newer ones
      # says so to the administrators, rather than failing (user guide, „Updates").
      DDM_DEPLOY_VERSION: "1"
      DATABASE_URL: postgres://ddm:${POSTGRES_PASSWORD}@postgres:5432/ddm
      BETTER_AUTH_URL: https://${DDM_CMS_HOST:?set DDM_CMS_HOST in .env}
      CLIENT_ORIGIN: https://${DDM_APP_HOST:?set DDM_APP_HOST in .env}
      # The documents, and the database's dumps taken before each migration
      # (the last three) — unless `S3_ENDPOINT` names a bucket for both.
      STORAGE_DIR: /data/storage
      BACKUP_DIR: /data/backups
      # Uploads are stored here and drawn by the render worker.
      RENDER_MODE: worker
      MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-1073741824}
    volumes:
      - data:/data
    ports:
      - ${DDM_CMS_PORT:-127.0.0.1:3000}:3000
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: [CMD, bun, -e, "process.exit((await fetch('http://127.0.0.1:3000/api/auth/ok')).ok ? 0 : 1)"]
      interval: 10s
      timeout: 5s
      retries: 6
      # Migrations first, on a new release.
      start_period: 5m
      start_interval: 2s

  render-worker:
    <<: *app
    image: ${DDM_REGISTRY:-registry.gitlab.com/iq-me/new-ddm-images}/render-worker:${DDM_VERSION:-latest}
    environment:
      DATABASE_URL: postgres://ddm:${POSTGRES_PASSWORD}@postgres:5432/ddm
      STORAGE_DRIVER: ${STORAGE_DRIVER:-}
      STORAGE_DIR: /data/storage
      S3_ENDPOINT: ${S3_ENDPOINT:-}
      S3_BUCKET: ${S3_BUCKET:-}
      S3_REGION: ${S3_REGION:-}
      S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
      S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
      S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true}
      THUMBNAILS: ${THUMBNAILS:-on}
      RENDER_LARGE_OFFICE: ${RENDER_LARGE_OFFICE:-on}
    volumes:
      - data:/data
    # After the cms has migrated the database to this release.
    depends_on:
      cms:
        condition: service_healthy
    # It finishes the documents in hand before it stops — LibreOffice may take
    # minutes over a large deck.
    stop_grace_period: 11m

  client:
    <<: *app
    image: ${DDM_REGISTRY:-registry.gitlab.com/iq-me/new-ddm-images}/client:${DDM_VERSION:-latest}
    environment:
      NUXT_API_ORIGIN: http://cms:3000
      NUXT_PUBLIC_MGMT_URL: https://${DDM_CMS_HOST}
      # The reader app on this installation, for the mobile app's „Server".
      NUXT_PUBLIC_SERVER_URL: https://${DDM_APP_HOST}
      RENDER_MODE: worker
      THUMBNAILS: ${THUMBNAILS:-on}
      NUXT_PUBLIC_SSO_PROVIDERS: ${NUXT_PUBLIC_SSO_PROVIDERS:-}
    ports:
      - ${DDM_APP_PORT:-127.0.0.1:3001}:3000
    depends_on:
      cms:
        condition: service_healthy
    # Through to the cms: what the reader app forwards answers, too.
    healthcheck:
      test: [CMD, bun, -e, "process.exit((await fetch('http://127.0.0.1:3000/api/auth/ok')).ok ? 0 : 1)"]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 1m
      start_interval: 2s

  # TLS and both hostnames, with certificates from Let's Encrypt: only with
  # `--profile proxy`, for a server with nothing else on ports 80 and 443.
  caddy:
    image: caddy:2.10-alpine
    profiles: [proxy]
    restart: unless-stopped
    environment:
      DDM_CMS_HOST: ${DDM_CMS_HOST}
      DDM_APP_HOST: ${DDM_APP_HOST}
    ports:
      - 80:80
      - 443:443
      - 443:443/udp
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy:/data
    depends_on: [cms, client]

volumes:
  postgres:
  data:
  caddy:

.env – your settings. What you fill in at least is in the next step.

.env
# An installation's settings, read by `compose.yaml` — copied to `.env` and
# filled in. Every setting of the user guide's „Konfiguration" /
# „Configuration" may be added; these are the ones an installation needs, and
# the ones most change.

# The release to run, e.g. `0.26.0`, or `0.26` for its fixes as they come.
# `stable` follows the releases we have promoted — for an installation that
# updates itself (user guide, „Updates").
DDM_VERSION=latest
# DDM_REGISTRY=registry.gitlab.com/iq-me/new-ddm-images

# The two hostnames, each with its own certificate: the management app (and
# API), and the reader app. DNS for both points at this server.
DDM_CMS_HOST=cms.example.com
DDM_APP_HOST=app.example.com
# Their parent, so a sign-in on one counts on the other.
AUTH_COOKIE_DOMAIN=example.com
# …and a passkey in both apps.
PASSKEY_RP_ID=example.com
# Where the two apps answer on this server, for your own reverse proxy —
# unused with `--profile proxy`.
# DDM_CMS_PORT=127.0.0.1:3000
# DDM_APP_PORT=127.0.0.1:3001

# Secrets: `openssl rand -base64 32` for each. The database's password is
# kept by Postgres on its first start; changing it here later locks the cms out.
POSTGRES_PASSWORD=
BETTER_AUTH_SECRET=

# The licence we sent you (`ddm1.…`) — needed from the first start: without
# one, the installation is locked, and nobody can register its first account.
# A licence entered later under Settings › Licence replaces it.
LICENCE_KEY=

# How mail leaves the installation — your own mail server.
MAIL_DRIVER=smtp
MAIL_FROM=ddm@example.com
SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
# SMTP_ALLOW_SELF_SIGNED=true   # for an internal server with its own CA

# The documents are kept on the `data` volume. To keep them in an
# S3-compatible bucket instead (MinIO, Ceph, a cloud's), name it here; the
# database's dumps then go to the same bucket.
# S3_ENDPOINT=https://s3.example.com
# S3_BUCKET=ddm
# S3_REGION=eu-central-1
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=

# Thumbnails and previews of uploaded documents: `off` leaves them out.
# THUMBNAILS=on

Caddyfile – only for the bundled reverse proxy (step 4).

Caddyfile
# The reverse proxy of `compose.yaml`'s `proxy` profile: TLS for both
# hostnames, with certificates Caddy fetches itself — the server has to be
# reachable on ports 80 and 443 under both names. Caddy sets no limit on a
# request's size, so uploads up to `MAX_UPLOAD_BYTES` pass; another proxy needs
# it raised (nginx: `client_max_body_size 1g`).
{$DDM_CMS_HOST} {
    encode zstd gzip
    reverse_proxy cms:3000
}

{$DDM_APP_HOST} {
    encode zstd gzip
    reverse_proxy client:3000
}

3. Fill in the settings

Fill in at least this in .env:

DDM_VERSION=0.26          # the version; 0.26 gets its bug fixes
DDM_CMS_HOST=cms.company.com
DDM_APP_HOST=app.company.com
AUTH_COOKIE_DOMAIN=company.com
PASSKEY_RP_ID=company.com

POSTGRES_PASSWORD=...     # openssl rand -base64 32
BETTER_AUTH_SECRET=...    # openssl rand -base64 32

LICENCE_KEY=ddm1....      # the licence from us

MAIL_DRIVER=smtp
MAIL_FROM=ddm@company.com
SMTP_HOST=mail.company.com
SMTP_PORT=587
SMTP_USER=...
SMTP_PASSWORD=...
  • LICENCE_KEY is needed from the start. Without a licence the installation starts locked, and nobody can create the first account.
  • POSTGRES_PASSWORD is taken over by Postgres on the first start. Don't change it in .env after that – otherwise the CMS can no longer reach the database.
  • AUTH_COOKIE_DOMAIN and PASSKEY_RP_ID are the domain both host names share. That way a sign-in and a passkey in the CMS also work in the reader app.

All other settings are listed under Configuration.

4. Choose the reverse proxy

On the server, the CMS and the reader app only answer locally, on 127.0.0.1:3000 and 127.0.0.1:3001. In front of them goes a reverse proxy that handles TLS:

  • The bundled Caddy, if nothing else runs on ports 80 and 443. It fetches the certificates for both host names itself. Start with --profile proxy in that case.
  • Your own proxy (nginx, Traefik, a load balancer). Route DDM_CMS_HOST to port 3000 and DDM_APP_HOST to port 3001. Allow requests of up to 1 GB, or large uploads fail – with nginx, client_max_body_size 1g;.

5. Start

docker compose up -d                   # with your own proxy
docker compose --profile proxy up -d   # with Caddy

On the first start the CMS sets up the database. This takes a minute; after that, docker compose ps shows the services as healthy.

6. Create the first account

Open https://cms.company.com and register – with your name, your company and your e-mail address. This creates your organisation, and you are its owner. Then invite your colleagues under Users or connect your directory under Settings › Directory services.

The licence states how many organisations your installation may have. Once that number is reached, nobody can create a new one.

The data

VolumeContents
ddm_postgresThe database.
ddm_dataThe documents (storage/) and the database backups (backups/).
ddm_caddyThe certificates of the bundled proxy.

If the documents should live in an S3 bucket, enter it under S3_ENDPOINT, S3_BUCKET and the keys. The backups then go to the same bucket.