# open-isms, self-hosted from the published image.
#
#   curl -o compose.yaml   https://nisd2.eu/install/compose.self-host.yml
#   curl -o .env           https://nisd2.eu/install/env.self-host.example
#   curl -o Caddyfile      https://nisd2.eu/install/Caddyfile.self-host.example
#   # fill in .env, then:
#   docker compose up -d
#
# The Caddyfile is only read by the `proxy` profile, but fetch it now: docker
# creates an empty DIRECTORY where a missing bind mount should be, and the
# resulting error names neither the file nor the cause.
#
# Nothing is built here and there is no need to clone the repository, which is
# also why the pointers below are URLs: you have this file on a server with no
# checkout, so a repo-relative path would be nothing you could open.
#
#   https://www.nisd2.eu/docs/self-hosting/installation   full walkthrough
#   https://www.nisd2.eu/docs/self-hosting/updating       updates and rollbacks
#
# ---------------------------------------------------------------------------
# Profiles
# ---------------------------------------------------------------------------
# Every optional service is defined in this file but only runs when you ask
# for its profile. A service behind an inactive profile is never created: no
# container, no port, no Docker socket. Turning one on later is one command,
# never an edit to this file — which is the point, because this file lives on
# your server and we cannot update it for you.
#
#   --profile minio     bundled object store, if you are not using real S3
#   --profile proxy     Caddy on 443 with automatic HTTPS
#   --profile updater   in-app update button
#                       (nisd2.eu/docs/self-hosting/updating)
#   --profile backup    scheduled offsite backups
#                       (nisd2.eu/docs/self-hosting/backup-and-restore)
#
# Combine them:
#   docker compose --profile minio --profile proxy --profile backup up -d
#
# Or set them once in .env and just run `docker compose up -d`:
#   COMPOSE_PROFILES=minio,proxy,backup
# ---------------------------------------------------------------------------
# A note on required values: only POSTGRES_PASSWORD and AUTH_SECRET are
# enforced by this file. Everything a profile needs is left optional, because
# compose interpolates every service in the file whether or not its profile is
# active — marking the updater's token mandatory would demand it from people
# who never turn the updater on. What each profile needs is listed in .env,
# and a profile with a missing value fails when you start it, not before.
# ---------------------------------------------------------------------------

services:
  postgres:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-openisms}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_DB: ${POSTGRES_DB:-openisms}
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - database-dump:/dump
    # Loopback by default: reachable from the server itself for maintenance,
    # invisible from the network. Note that publishing a port on 0.0.0.0 here
    # would bypass ufw — Docker writes its own iptables rules ahead of it.
    ports:
      - "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER:-openisms}", "-d", "${POSTGRES_DB:-openisms}"]
      interval: 5s
      timeout: 5s
      retries: 10
    labels:
      # Read by the backup profile: dump the database to a file inside the
      # shared volume before the archive is taken, so the backup holds a
      # consistent logical dump rather than a half-written data directory.
      # A logical dump also restores into a newer Postgres major version,
      # which a copy of the data directory cannot.
      docker-volume-backup.archive-pre: >-
        /bin/sh -c 'pg_dump -U ${POSTGRES_USER:-openisms} -d ${POSTGRES_DB:-openisms} --clean --if-exists -f /dump/database.sql'
      docker-volume-backup.archive-post: "/bin/sh -c 'rm -f /dump/database.sql'"

  app:
    # Pinned by tag. `stable` tracks the current release; set OPEN_ISMS_VERSION
    # to an exact version (e.g. 0.2.8) to pin, and to roll back.
    image: ghcr.io/nisd2/open-isms:${OPEN_ISMS_VERSION:-stable}
    restart: unless-stopped
    environment:
      # Required. The app refuses to start without these.
      DATABASE_URL: postgres://${POSTGRES_USER:-openisms}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:-openisms}
      AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET in .env, generate with openssl rand -base64 32}
      AUTH_URL: ${AUTH_URL:-http://localhost:3026}
      NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3026}
      # Required before anyone can register: sign-up verifies the address with
      # a one-time code, so with no mail transport nobody can complete a login.
      # Two transports, pick one. SMTP_HOST wins when both are set.
      SMTP_HOST: ${SMTP_HOST:-}
      SMTP_PORT: ${SMTP_PORT:-587}
      SMTP_USER: ${SMTP_USER:-}
      SMTP_PASSWORD: ${SMTP_PASSWORD:-}
      SMTP_SECURE: ${SMTP_SECURE:-}
      SMTP_ALLOW_SELF_SIGNED: ${SMTP_ALLOW_SELF_SIGNED:-}
      RESEND_API_KEY: ${RESEND_API_KEY:-}
      # From address for either transport. RESEND_FROM_EMAIL is the older name
      # and still works.
      MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
      MAIL_FROM_NAME: ${MAIL_FROM_NAME:-}
      MAIL_FROM_NAME_PERSONAL: ${MAIL_FROM_NAME_PERSONAL:-}
      RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
      # Third way in, for an instance with no mail transport at all: the first
      # account is created at startup instead of being verified by email.
      # Read at boot by scripts/runtime-migrate.mjs; the app never reads them.
      BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-}
      BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
      # Required in production: GDPR erasure throws without it.
      ERASURE_EMAIL_HASH_SALT: ${ERASURE_EMAIL_HASH_SALT:-}
      # Which version of THIS file you are running. The app compares it against
      # what the release expects and tells you when an update also needs a
      # change here, instead of letting your deployment quietly drift.
      COMPOSE_REVISION: ${COMPOSE_REVISION:-3}
      # Not implemented: no application code reads this, and setting it has
      # no effect today. Passed through so the variable exists where it will
      # be read once it is built. Checking for updates is manual for now, see
      # nisd2.eu/docs/self-hosting/updating.
      # Planned: `notify` = one GET a day against the GitHub
      # releases API, no instance identifier and no telemetry.
      UPDATE_MODE: ${UPDATE_MODE:-off}
      UPDATE_API_TOKEN: ${UPDATE_API_TOKEN:-}
      # Optional. Each one degrades a single feature, see
      # nisd2.eu/docs/self-hosting/configuration.
      GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
      GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
      AWS_S3_REGION: ${AWS_S3_REGION:-eu-north-1}
      AWS_S3_BUCKET: ${AWS_S3_BUCKET:-}
      AWS_S3_ENDPOINT: ${AWS_S3_ENDPOINT:-}
      AWS_S3_INTERNAL_ENDPOINT: ${AWS_S3_INTERNAL_ENDPOINT:-}
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
      XAI_API_KEY: ${XAI_API_KEY:-}
      RAPIDAPI_KEY: ${RAPIDAPI_KEY:-}
      CRON_SECRET: ${CRON_SECRET:-}
      SUPPORT_EMAIL: ${SUPPORT_EMAIL:-}
      PLATFORM_ADMIN_EMAILS: ${PLATFORM_ADMIN_EMAILS:-}
      CSP_UPGRADE_INSECURE: ${CSP_UPGRADE_INSECURE:-}
    # Loopback by default. Under `--profile proxy` the proxy reaches this over
    # the compose network and nothing needs publishing at all; set APP_BIND to
    # 0.0.0.0 only if you are deliberately exposing the app port directly.
    ports:
      - "${APP_BIND:-127.0.0.1}:${APP_PORT:-3026}:3000"
    depends_on:
      postgres:
        condition: service_healthy
    labels:
      # Scopes the updater: it will only ever touch containers carrying this.
      com.centurylinklabs.watchtower.enable: "true"

  # --------------------------------------------------------------------- #
  # profile: mail — a mail server for evaluating, so registration works
  # before you own a relay
  # --------------------------------------------------------------------- #
  # Add `mail` to COMPOSE_PROFILES and set SMTP_HOST=mailpit, SMTP_PORT=1025.
  # Every message the app sends is caught here and readable at
  # http://localhost:8025; nothing leaves the machine, and mail is held in
  # memory so a restart empties it.
  #
  # For evaluating only. A real instance points SMTP_HOST at a relay that can
  # deliver to the internet, or sets RESEND_API_KEY instead.
  mailpit:
    image: axllent/mailpit:latest
    profiles: [mail]
    restart: unless-stopped
    ports:
      # The web inbox, on loopback. SMTP stays on the compose network, where
      # the app reaches it as mailpit:1025, so this is not an open relay.
      - "${MAILPIT_BIND:-127.0.0.1}:${MAILPIT_UI_PORT:-8025}:8025"
    healthcheck:
      test: ["CMD", "/mailpit", "readyz"]
      interval: 5s
      timeout: 5s
      retries: 20

  # --------------------------------------------------------------------- #
  # profile: minio — bundled object store for evidence uploads
  # --------------------------------------------------------------------- #
  minio:
    # Chainguard's build of the MinIO server, not MinIO's own images. MinIO's
    # images stopped being pullable without an account: Docker Hub first
    # ("pull access denied"), then quay.io on 25.09.2026 ("unauthorized"), for
    # every tag including pinned releases. Anyone bringing the minio profile up
    # got a stack that would not start, and it took out three CI jobs.
    # Chainguard publishes `latest` free and rebuilds it with security fixes.
    # The image carries minio, mc and bash, but no curl (see the healthcheck).
    image: cgr.dev/chainguard/minio:latest
    profiles: [minio]
    restart: unless-stopped
    # The image runs as a non-root user by default, but every existing
    # minio-data volume was written by MinIO's own image as root. Root keeps
    # those volumes readable across the image change.
    user: "0:0"
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-}
      MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-}
      # Evidence uploads always send x-amz-server-side-encryption AES256.
      # Without a KMS key every upload is rejected.
      MINIO_KMS_SECRET_KEY: "openisms-key:${MINIO_KMS_KEY:-}"
    volumes:
      - minio-data:/data
    ports:
      - "${MINIO_BIND:-127.0.0.1}:${MINIO_PORT:-9000}:9000"
    healthcheck:
      # No curl or grep in the image, so the liveness request is made with
      # bash's /dev/tcp and the status line matched in bash itself.
      test: ["CMD", "/bin/sh", "-c", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /minio/health/live HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && read -r status <&3 && [[ $$status == *' 200 '* ]]"]
      interval: 5s
      timeout: 5s
      retries: 20

  minio-init:
    # The server image carries mc too, so one image serves both services.
    image: cgr.dev/chainguard/minio:latest
    profiles: [minio]
    depends_on:
      minio:
        condition: service_healthy
    environment:
      MC_HOST_store: http://${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}@minio:9000
    entrypoint: ["/bin/sh", "-c", "mc mb --ignore-existing store/${AWS_S3_BUCKET:-evidence} && echo 'bucket ${AWS_S3_BUCKET:-evidence} ready'"]

  # --------------------------------------------------------------------- #
  # profile: proxy — the only service that should ever publish a public port
  # --------------------------------------------------------------------- #
  # Caddy obtains and renews certificates automatically. Point APP_DOMAIN (and
  # STORAGE_DOMAIN, if you use the bundled MinIO) at this server first, or the
  # certificate order fails.
  proxy:
    image: caddy:2-alpine
    profiles: [proxy]
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    environment:
      APP_DOMAIN: ${APP_DOMAIN:-}
      STORAGE_DOMAIN: ${STORAGE_DOMAIN:-}

  # --------------------------------------------------------------------- #
  # profile: updater — lets the in-app update button swap this stack's image
  # --------------------------------------------------------------------- #
  # Passive by design: no ports are published, it is reachable only from the
  # app over the compose network, and with no periodic-polls setting it never
  # acts on its own. It updates when the app asks it to, which happens only
  # after a human clicks. Leave this profile off and updates stay a two-command
  # job on the CLI, which is a perfectly good way to run.
  #
  # It does hold the Docker socket, which is root-equivalent on the host. That
  # is unavoidable for any self-update mechanism, including the one built into
  # Coolify or Portainer. The app never holds the socket itself.
  updater:
    image: nickfedor/watchtower:1.21.2
    profiles: [updater]
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      # The image is for licensees, so the updater pulls with the host's login
      # (docker login ghcr.io) when docker stored it in config.json. The
      # directory, not the file: a missing file would be created as an empty
      # directory on the host and break the mount.
      - ${DOCKER_CONFIG:-~/.docker}:/root/.docker:ro
    environment:
      DOCKER_CONFIG: /root/.docker
      # Docker Desktop keeps logins in the OS keychain, which no container can
      # read; the installer therefore writes the licensee login into .env and
      # the updater falls back to it.
      REPO_USER: ${GHCR_USER:-}
      REPO_PASS: ${GHCR_TOKEN:-}
      WATCHTOWER_HTTP_API_ENDPOINTS: "update"
      WATCHTOWER_HTTP_API_TOKEN: ${UPDATE_API_TOKEN:-}
      # Only containers labelled com.centurylinklabs.watchtower.enable=true,
      # i.e. the app and nothing else on this host.
      WATCHTOWER_LABEL_ENABLE: "true"
      # Deliberately off. Cleanup deletes the image the previous version ran
      # from, which is the one thing you need if the update goes wrong:
      # rollback is "pin the old version and start it again", and that only
      # works instantly while the image is still on disk. Keeping it costs
      # about a gigabyte per release; needing it and not having it costs an
      # outage extended by however long a re-pull takes, or forever on a
      # machine with no internet. Reclaim space deliberately with
      # `docker image prune` once an update has proven itself.
      WATCHTOWER_CLEANUP: "false"
      WATCHTOWER_NO_STARTUP_MESSAGE: "true"

  # --------------------------------------------------------------------- #
  # profile: backup — scheduled, encrypted, offsite
  # --------------------------------------------------------------------- #
  # One archive holds both halves of the instance: the SQL dump written by the
  # pre-backup command on the postgres service, and the evidence objects. They
  # have to be restored together — the database stores object keys and the
  # store holds the bytes, so either half alone restores nothing usable.
  backup:
    image: offen/docker-volume-backup:v2.48.2
    profiles: [backup]
    restart: unless-stopped
    environment:
      BACKUP_CRON_EXPRESSION: ${BACKUP_CRON:-0 3 * * *}
      # The extension is filled in by the tool, because turning encryption on
      # changes it. Hardcoding .tar.gz produces files that are not what they
      # claim to be.
      BACKUP_FILENAME: openisms-%Y-%m-%dT%H-%M-%S.{{ .Extension }}
      BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30}
      BACKUP_PRUNING_PREFIX: openisms-
      # Where the archive goes. Any S3-compatible target; the host goes in
      # without a scheme, the scheme goes in the PROTO variable. Leaving the
      # bucket empty keeps archives in the backup-archive volume on this
      # machine only, which is a copy, not a backup: it dies with the server.
      AWS_S3_BUCKET_NAME: ${BACKUP_S3_BUCKET:-}
      AWS_ENDPOINT: ${BACKUP_S3_ENDPOINT:-s3.amazonaws.com}
      AWS_ENDPOINT_PROTO: ${BACKUP_S3_ENDPOINT_PROTO:-https}
      AWS_ACCESS_KEY_ID: ${BACKUP_S3_ACCESS_KEY:-}
      AWS_SECRET_ACCESS_KEY: ${BACKUP_S3_SECRET_KEY:-}
      # Encrypts the archive before it leaves the machine. Compliance
      # evidence should not sit unencrypted in someone else's object store.
      # Keep this passphrase somewhere other than this server: without it the
      # archives cannot be read, including by you.
      GPG_PASSPHRASE: ${BACKUP_PASSPHRASE:-}
      NOTIFICATION_URLS: ${BACKUP_NOTIFICATION_URL:-}
      NOTIFICATION_LEVEL: error
    volumes:
      - database-dump:/backup/database:ro
      - minio-data:/backup/evidence:ro
      - backup-archive:/archive
      # Needed to run the pre-backup dump inside the postgres container.
      # Mounting a socket :ro does NOT make the Docker API read-only — this
      # container can do anything the daemon can. It is in the stack because
      # a backup nobody takes is worse; drop the profile if that trade is
      # wrong for you and run pg_dump from outside instead.
      - /var/run/docker.sock:/var/run/docker.sock

volumes:
  postgres-data:
  database-dump:
  minio-data:
  backup-archive:
  caddy-data:
  caddy-config:
