compose.yaml
Rama main · réplica de lectura del repositorio original.
# Self-hosting — the "three commands" promise:
# git clone https://github.com/Open-Incident/open-incident && cd open-incident
# cp .env.example .env # then fill in the secrets
# docker compose up -d
#
# The application then answers on http://localhost:3000 and the status pages on
# http://<page>.status.localhost:3001 — unless BASE_DOMAIN / STATUS_BASE_DOMAIN
# in .env name your real domains.
# With SEED_DEMO=true (the .env.example default), the instance starts with the
# Skylark Systems demo workspace — login amelie@skylark.dev /
# demo-openincident — to be replaced by your own workspace from the first
# session on (`docker compose run --rm migrate pnpm workspace:create …`).
#
# The internal hosts (postgres, redis) are pinned here and take precedence over
# .env, written for development on the host machine's ports. Two database
# roles: the owner runs the migrations, the application role runs the product
# under row-level security — it must never be the same login.
name: open-incident-prod
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-openincident}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openincident}
POSTGRES_DB: ${POSTGRES_DB:-openincident}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openincident}"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:8-alpine
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
migrate:
build:
context: .
dockerfile: docker/Dockerfile
target: migrate
env_file:
- .env
environment:
# Blanked on purpose. A .env copied from a development machine carries
# NODE_EXTRA_CA_CERTS pointing at mkcert's authority on that machine, and
# Node warns about the missing file on every line this container logs.
# Development TLS is not a container's business.
NODE_EXTRA_CA_CERTS: ""
DATABASE_ADMIN_URL: postgres://${POSTGRES_USER:-openincident}:${POSTGRES_PASSWORD:-openincident}@postgres:5432/${POSTGRES_DB:-openincident}
DATABASE_URL: postgres://openincident_app:${APP_DB_PASSWORD:-openincident_app}@postgres:5432/${POSTGRES_DB:-openincident}
# Empty unless the telemetry profile is on; the column store's migrations
# are skipped rather than failing when there is no store to migrate.
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-}
# CLICKHOUSE_DATABASE here, CLICKHOUSE_DB on the store itself: the image's
# entrypoint reads the second name and our client reads the first, so one
# variable in .env feeds both rather than two that can drift apart.
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DB:-openincident}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-openincident}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-openincident}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-openincident_app}
SEED_DEMO: ${SEED_DEMO:-true}
BASE_DOMAIN: ${BASE_DOMAIN:-localhost:3000}
STATUS_BASE_DOMAIN: ${STATUS_BASE_DOMAIN:-status.localhost:3001}
depends_on:
postgres:
condition: service_healthy
web:
build:
context: .
dockerfile: docker/Dockerfile
target: web
env_file:
- .env
environment:
# Blanked on purpose. A .env copied from a development machine carries
# NODE_EXTRA_CA_CERTS pointing at mkcert's authority on that machine, and
# Node warns about the missing file on every line this container logs.
# Development TLS is not a container's business.
NODE_EXTRA_CA_CERTS: ""
DATABASE_URL: postgres://openincident_app:${APP_DB_PASSWORD:-openincident_app}@postgres:5432/${POSTGRES_DB:-openincident}
REDIS_URL: redis://redis:6379
# The public addresses: your domains in .env, the compose ports by default.
BASE_DOMAIN: ${BASE_DOMAIN:-localhost:3000}
STATUS_BASE_DOMAIN: ${STATUS_BASE_DOMAIN:-status.localhost:3001}
# Empty unless the telemetry profile is on. Every telemetry screen checks
# it and says the module is not installed rather than drawing nothing.
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-}
# CLICKHOUSE_DATABASE here, CLICKHOUSE_DB on the store itself: the image's
# entrypoint reads the second name and our client reads the first, so one
# variable in .env feeds both rather than two that can drift apart.
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DB:-openincident}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-openincident}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-openincident}
# Where a collector reaches the ingestion endpoint, for the Connect
# screen to print an address somebody can paste.
TELEMETRY_PUBLIC_ORIGIN: ${TELEMETRY_PUBLIC_ORIGIN:-}
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
restart: unless-stopped
# The public status pages: reads snapshots only, no Redis — stays up when
# the product is down. Point status.<your domain> (and customers' CNAMEs) here.
status:
build:
context: .
dockerfile: docker/Dockerfile
target: status
env_file:
- .env
environment:
# Blanked on purpose. A .env copied from a development machine carries
# NODE_EXTRA_CA_CERTS pointing at mkcert's authority on that machine, and
# Node warns about the missing file on every line this container logs.
# Development TLS is not a container's business.
NODE_EXTRA_CA_CERTS: ""
DATABASE_URL: postgres://openincident_app:${APP_DB_PASSWORD:-openincident_app}@postgres:5432/${POSTGRES_DB:-openincident}
BASE_DOMAIN: ${BASE_DOMAIN:-localhost:3000}
STATUS_BASE_DOMAIN: ${STATUS_BASE_DOMAIN:-status.localhost:3001}
ports:
- "${STATUS_PORT:-3001}:3001"
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
# One worker serves every queue, and that is the default: an installation
# must never need two containers to send an email. WORKER_QUEUES is what
# changes that — see the `worker-bulk` service below, and .env.example.
worker:
build:
context: .
dockerfile: docker/Dockerfile
target: worker
env_file:
- .env
environment:
# Blanked on purpose. A .env copied from a development machine carries
# NODE_EXTRA_CA_CERTS pointing at mkcert's authority on that machine, and
# Node warns about the missing file on every line this container logs.
# Development TLS is not a container's business.
NODE_EXTRA_CA_CERTS: ""
DATABASE_URL: postgres://openincident_app:${APP_DB_PASSWORD:-openincident_app}@postgres:5432/${POSTGRES_DB:-openincident}
REDIS_URL: redis://redis:6379
INTERNAL_WEB_ORIGIN: http://web:3000
BASE_DOMAIN: ${BASE_DOMAIN:-localhost:3000}
STATUS_BASE_DOMAIN: ${STATUS_BASE_DOMAIN:-status.localhost:3001}
WORKER_QUEUES: ${WORKER_QUEUES:-}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-}
# Empty unless the telemetry profile is on. The sweeps that read the
# column store — telemetry monitors, the service map, exception
# regressions, the SLOs — all check for it and do nothing without one.
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-}
# CLICKHOUSE_DATABASE here, CLICKHOUSE_DB on the store itself: the image's
# entrypoint reads the second name and our client reads the first, so one
# variable in .env feeds both rather than two that can drift apart.
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DB:-openincident}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-openincident}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-openincident}
# The ping monitor sends ICMP from an unprivileged datagram socket. Docker
# has set this range in every container since 2020, so this line changes
# nothing today — it is written down so a future default cannot take the
# feature away without anyone noticing.
sysctls:
net.ipv4.ping_group_range: "0 2147483647"
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
restart: unless-stopped
# A second worker for the heavy work — off by default, on purpose.
#
# An installation only needs it once the background work starts crowding
# itself out: the fan-out to a status page's subscribers, the purges, the
# nightly syncs. Starting it means giving the first worker the other half,
# otherwise both serve everything and nothing is separated:
# WORKER_QUEUES=urgent docker compose --profile split up -d worker worker-bulk
# The image is the same one; only the variable differs. A single process
# remains the default, and it is the one `docker compose up` starts.
worker-bulk:
build:
context: .
dockerfile: docker/Dockerfile
target: worker
profiles: ["split"]
env_file:
- .env
environment:
# Blanked on purpose. A .env copied from a development machine carries
# NODE_EXTRA_CA_CERTS pointing at mkcert's authority on that machine, and
# Node warns about the missing file on every line this container logs.
# Development TLS is not a container's business.
NODE_EXTRA_CA_CERTS: ""
DATABASE_URL: postgres://openincident_app:${APP_DB_PASSWORD:-openincident_app}@postgres:5432/${POSTGRES_DB:-openincident}
REDIS_URL: redis://redis:6379
INTERNAL_WEB_ORIGIN: http://web:3000
BASE_DOMAIN: ${BASE_DOMAIN:-localhost:3000}
STATUS_BASE_DOMAIN: ${STATUS_BASE_DOMAIN:-status.localhost:3001}
WORKER_QUEUES: bulk
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-}
# CLICKHOUSE_DATABASE here, CLICKHOUSE_DB on the store itself: the image's
# entrypoint reads the second name and our client reads the first, so one
# variable in .env feeds both rather than two that can drift apart.
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DB:-openincident}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-openincident}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-openincident}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-}
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
restart: unless-stopped
# The browser runner for synthetic monitors — off by default, on purpose.
# Its image carries Chromium (~400 MB) and a run costs several hundred
# megabytes of memory, so an ordinary `docker compose up` never pulls it:
# docker compose --profile synthetic up -d synthetic
# While it runs it keeps a key alive in Redis, and that is what makes the
# synthetic type selectable in the product. Stop it and the type goes back to
# greyed, with this command as its reason.
synthetic:
build:
context: .
dockerfile: docker/Dockerfile
target: synthetic
profiles: ["synthetic"]
env_file:
- .env
environment:
# Blanked on purpose. A .env copied from a development machine carries
# NODE_EXTRA_CA_CERTS pointing at mkcert's authority on that machine, and
# Node warns about the missing file on every line this container logs.
# Development TLS is not a container's business.
NODE_EXTRA_CA_CERTS: ""
DATABASE_URL: postgres://openincident_app:${APP_DB_PASSWORD:-openincident_app}@postgres:5432/${POSTGRES_DB:-openincident}
REDIS_URL: redis://redis:6379
INTERNAL_WEB_ORIGIN: http://web:3000
BASE_DOMAIN: ${BASE_DOMAIN:-localhost:3000}
STATUS_BASE_DOMAIN: ${STATUS_BASE_DOMAIN:-status.localhost:3001}
# How many journeys play at once. Each is a Chromium context: raising it
# on a one-CPU host makes every monitor report the queue's latency
# instead of the site's.
SYNTHETIC_CONCURRENCY: ${SYNTHETIC_CONCURRENCY:-2}
# Chromium writes its shared memory to /dev/shm, and Docker's default 64 MB
# is not enough for a real page: the browser dies mid-journey and the
# monitor reports an outage that is ours, not the site's.
shm_size: "1gb"
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
restart: unless-stopped
# ---------- Observability (spec 15), behind the `telemetry` profile ----------
#
# Off by default, and that is a choice rather than an omission. A column
# store is a second database to run, back up and watch, and the incident
# product is whole without one: an installation that only wants alerting and
# on-call should not be paying for ClickHouse.
#
# Turning it on is one command and two variables:
# docker compose --profile telemetry up -d
# with CLICKHOUSE_URL set in .env so the web app, the worker and the
# migrations all know the store exists. Without it every telemetry screen
# says so and offers this line, rather than drawing empty charts.
clickhouse:
image: clickhouse/clickhouse-server:25.3-alpine
profiles: ["telemetry"]
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_DB:-openincident}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-openincident}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-openincident}
# Without this the entrypoint creates the user with no password at all,
# and ClickHouse then refuses every authenticated connection.
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
volumes:
- chdata:/var/lib/clickhouse
# ClickHouse opens a file per part and merges them constantly; the default
# 1024 is reached by a single busy table and the symptom is a store that
# stops accepting writes.
ulimits:
nofile: { soft: 262144, hard: 262144 }
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
# The OTLP endpoint your services send to. Its own container because it is
# the one part of the stack that faces the outside on a port of its own, and
# because a busy installation will want more than one of it without
# multiplying the web app.
telemetry:
build:
context: .
dockerfile: docker/Dockerfile
target: telemetry
profiles: ["telemetry"]
env_file:
- .env
environment:
# Blanked on purpose. A .env copied from a development machine carries
# NODE_EXTRA_CA_CERTS pointing at mkcert's authority on that machine, and
# Node warns about the missing file on every line this container logs.
# Development TLS is not a container's business.
NODE_EXTRA_CA_CERTS: ""
DATABASE_URL: postgres://openincident_app:${APP_DB_PASSWORD:-openincident_app}@postgres:5432/${POSTGRES_DB:-openincident}
CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123}
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DB:-openincident}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-openincident}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-openincident}
TELEMETRY_PORT: "4318"
ports:
# 4318 is OTLP/HTTP's registered port, so a collector pointed at this
# host needs no port in its endpoint.
- "${TELEMETRY_PORT:-4318}:4318"
depends_on:
migrate:
condition: service_completed_successfully
clickhouse:
condition: service_healthy
restart: unless-stopped
# Optional object storage for workspace files (logos). Start it with
# `docker compose --profile storage up -d` and set S3_* in .env accordingly,
# or point S3_* at any S3-compatible bucket you already run.
minio:
# quay.io, not Docker Hub: minio/minio there stopped being pullable in
# September 2026 ("repository does not exist"), and the nightly smoke ran
# into it four nights running before anyone noticed. Pinned to a release
# rather than latest for the same reason — a tag that moves under a
# scheduled job is a job that fails on someone else's schedule.
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
profiles: ["storage"]
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-openincident}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-openincident}
volumes:
- miniodata:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
minio-init:
# The client publishes no release tags on quay, so it is pinned by digest.
image: quay.io/minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727
profiles: ["storage"]
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 ${S3_ACCESS_KEY_ID:-openincident} ${S3_SECRET_ACCESS_KEY:-openincident} &&
mc mb --ignore-existing local/${S3_BUCKET:-openincident} &&
echo bucket ready"
volumes:
chdata:
pgdata:
redisdata:
miniodata: