# 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. See # docs/self-hosting.md for the full walkthrough, and docs/updating.md for how # updates and rollbacks work. # # --------------------------------------------------------------------------- # 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 (see docs/updating.md) # --profile backup scheduled offsite backups (see docs/backup.md) # # 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. RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-} # 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:-1} # 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 # docs/updating.md. 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 docs/self-hosting.md. 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: minio — bundled object store for evidence uploads # --------------------------------------------------------------------- # minio: image: minio/minio:latest profiles: [minio] restart: unless-stopped 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: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] interval: 5s timeout: 5s retries: 20 minio-init: image: minio/mc: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: