# open-isms, self-hosted from the published image. # # curl -o compose.yaml https://raw.githubusercontent.com/NISD2/open-isms/main/compose.self-host.yml # curl -o .env https://raw.githubusercontent.com/NISD2/open-isms/main/.env.self-host.example # curl -o Caddyfile https://raw.githubusercontent.com/NISD2/open-isms/main/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 environment: 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: