sha256:6b22cf6f698d19cdd2f57ec98f8f5a3c6eddd7f250901b25dcebbf4a82f332d7
Last pushed
3 days by mayasfaraj
Type
Compose
Manifest digest
sha256:6b22cf6f698d19cdd2f57ec98f8f5a3c6eddd7f250901b25dcebbf4a82f332d7
# Opsloop for one deployment outside dev (local, staging or production). It ships with each release
# and is included by the deployment folder's own compose.yaml (deploy/template/compose.yaml), which
# runs it in that folder: deploy.env and secrets.env (COMPOSE_ENV_FILES), the services' secrets/,
# and state/. README "Publish" has the commands.
#
# - Only the edge publishes ports (EDGE_HTTP, EDGE_HTTPS). Networks keep the rest apart (networks:
# at the end): the services are reached by the router and the edge (which proxies to the router
# only), never by Zitadel or its login UI; each PostgreSQL by its own users only.
# - Zitadel's first init waits for deployment-check, which checks deploy.env and the secrets'
# shapes first (zitadel-sync --check): what first init takes is kept for good.
# - Secrets reach containers as files, never as environment variables: secrets.env's values as
# configs, copied in (configs: below says why), and a service's secrets/<svc>.env bind-mounted,
# mode 0600, for the containers that run as DEPLOY_UID. `docker compose config` prints those
# configs: validate with `docker compose config -q`.
# - Every container of ours runs as DEPLOY_UID, the owner of the deployment folder.
# - One-shots: db-setup, zitadel-sync and router-config (profile ops) and <svc>-migrate, the only
# holder of a service's owner password (profile <svc>, with the service itself).
# - Our images are ${IMAGE_REGISTRY}/opsloop-<name>:${IMAGE_TAG} (deploy/compose.build.yaml); the
# others are pinned by digest, the same as in dev/compose.yaml (the compose spec checks both).
x-logging: &logging
driver: json-file
options:
max-size: 10m
max-file: '3'
x-deploy-user: &deploy-user ${DEPLOY_UID:?set in deploy.env}:${DEPLOY_GID:?set in deploy.env}
# The deployment's settings (no secret in either file) for the containers that read them.
x-deployment-env: &deployment-env
- path: deploy.env
required: true
- path: state/ids.env
required: false
services:
edge:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-edge:${IMAGE_TAG:?set in deploy.env}
restart: unless-stopped
init: true
# nginx's own master runs as root and its workers as nginx; the image renders into tmpfs.
read_only: true
tmpfs:
- /run
- /var/cache/nginx
env_file: *deployment-env
environment:
RELEASE: ${IMAGE_TAG:?set in deploy.env}
ports:
- ${EDGE_HTTP:?set in deploy.env}:80
- ${EDGE_HTTPS:?set in deploy.env}:443
networks: [public, mesh, auth]
volumes:
# The ACME account and certificates, or TLS_MODE=local's self-signed certificate.
- edge-state:/var/lib/opsloop-edge
stop_grace_period: 10s
logging: *logging
postgres:
image: postgres:18.6-trixie@sha256:5a5a84b19854a9ffaa54082c166ff4ec27473a361e496e5ea167f298f2da9722
restart: unless-stopped
init: true
environment:
# Read once, at the first start, by the entrypoint (as root, before it drops privileges).
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
configs:
- source: postgres-password
target: /run/secrets/postgres-password
networks: [data]
volumes:
# PostgreSQL 18 keeps its data under /var/lib/postgresql/18/docker; mount the parent.
- postgres-data:/var/lib/postgresql
healthcheck:
# Over TCP: the image's temporary first-boot server listens only on the socket.
test: [CMD-SHELL, pg_isready -h 127.0.0.1 -U postgres -d postgres]
interval: 5s
timeout: 5s
retries: 12
start_period: 30s
stop_grace_period: 60s
logging: *logging
zitadel-db:
image: postgres:18.6-trixie@sha256:5a5a84b19854a9ffaa54082c166ff4ec27473a361e496e5ea167f298f2da9722
restart: unless-stopped
init: true
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/zitadel-db-admin-password
configs:
- source: zitadel-db-admin-password
target: /run/secrets/zitadel-db-admin-password
networks: [zitadel-data]
volumes:
- zitadel-db-data:/var/lib/postgresql
healthcheck:
test: [CMD-SHELL, pg_isready -h 127.0.0.1 -U postgres -d postgres]
interval: 5s
timeout: 5s
retries: 12
start_period: 30s
stop_grace_period: 60s
logging: *logging
# One-shot: a new named volume belongs to root; Zitadel (DEPLOY_UID) writes its PATs into it.
zitadel-bootstrap:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-db-setup:${IMAGE_TAG:?set in deploy.env}
restart: 'no'
entrypoint:
[
sh,
-c,
'chown "$$DEPLOY_UID:$$DEPLOY_GID" /zitadel/bootstrap && chmod 0700 /zitadel/bootstrap',
]
environment:
DEPLOY_UID: ${DEPLOY_UID:?set in deploy.env}
DEPLOY_GID: ${DEPLOY_GID:?set in deploy.env}
network_mode: none
volumes:
- zitadel-bootstrap:/zitadel/bootstrap
logging: *logging
# One-shot, before Zitadel's first init can take a wrong value for good: deploy.env (libs/deployment),
# the shapes of secrets.env's values and the sync's own file. No network, no PAT.
deployment-check:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-zitadel-sync:${IMAGE_TAG:?set in deploy.env}
restart: 'no'
user: *deploy-user
command: [--check]
network_mode: none
volumes:
- ./deploy.env:/deployment/deploy.env:ro
- ./secrets.env:/deployment/secrets.env:ro
- type: bind
source: ./secrets/zitadel-sync.env
target: /deployment/secrets/zitadel-sync.env
read_only: true
bind:
create_host_path: false
logging: *logging
zitadel:
image: ghcr.io/zitadel/zitadel:v4.19.2@sha256:956a298405189a8f6cd4ec92e8828b1bd10caa7da8b139c39d251d7053e33784
restart: unless-stopped
init: true
user: *deploy-user
# The masterkey file is read as is: exactly 32 bytes, no newline (README "Publish").
command:
- start-from-init
- --masterkeyFile
- /run/secrets/zitadel-masterkey
- --tlsMode
- external
- --config
- /etc/zitadel/secrets.yaml
# First-init settings are setup steps, not config: a password in --config is ignored.
- --steps
- /etc/zitadel/steps.yaml
configs:
- source: zitadel-masterkey
target: /run/secrets/zitadel-masterkey
- source: zitadel-secrets
target: /etc/zitadel/secrets.yaml
- source: zitadel-steps
target: /etc/zitadel/steps.yaml
environment:
ZITADEL_PORT: 8080
# Fixed at first init: the URL browsers use, behind the edge on 443.
ZITADEL_EXTERNALDOMAIN: ${AUTH_DOMAIN:?set in deploy.env}
ZITADEL_EXTERNALPORT: 443
ZITADEL_EXTERNALSECURE: 'true'
ZITADEL_TLS_ENABLED: 'false'
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_FIRSTINSTANCE_INSTANCENAME: ${DEPLOYMENT:?set in deploy.env}
# zitadel-sync finds this org by its name: the first org of ORGS.
ZITADEL_FIRSTINSTANCE_ORG_NAME: ${ZITADEL_FIRST_ORG_NAME:?set in deploy.env}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME:?set in deploy.env}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_FIRSTNAME: Instance
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_LASTNAME: Admin
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS: ${ZITADEL_ADMIN_EMAIL:?set in deploy.env}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: 'true'
# The desired-state sync's account and the login UI's: their PATs are written once, at first
# init, into the bootstrap volume (never into state/). Nothing renews a PAT yet: the login
# UI's lasts (every sign-in, the console's included, goes through it); the sync's expires
# when deploy.env says (docs/deployment.md "Bootstrap PATs").
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-sync
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Opsloop desired-state sync
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: ${ZITADEL_BOOTSTRAP_PAT_EXPIRES:?set in deploy.env}
ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel/bootstrap/zitadel-sync.pat
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Login UI client
ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: '2099-01-01T00:00:00Z'
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel/bootstrap/login-client.pat
ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWREGISTER: 'false'
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: 'true'
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: https://${AUTH_DOMAIN:?set in deploy.env}/ui/v2/login/
ZITADEL_OIDC_DEFAULTLOGINURLV2: https://${AUTH_DOMAIN:?set in deploy.env}/ui/v2/login/login?authRequest=
ZITADEL_OIDC_DEFAULTLOGOUTURLV2: https://${AUTH_DOMAIN:?set in deploy.env}/ui/v2/login/logout?post_logout_redirect=
ZITADEL_SAML_DEFAULTLOGINURLV2: https://${AUTH_DOMAIN:?set in deploy.env}/ui/v2/login/login?samlRequest=
networks: [auth, zitadel-data]
volumes:
- zitadel-bootstrap:/zitadel/bootstrap
depends_on:
deployment-check:
condition: service_completed_successfully
zitadel-db:
condition: service_healthy
zitadel-bootstrap:
condition: service_completed_successfully
healthcheck:
test: [CMD, /app/zitadel, ready]
interval: 10s
timeout: 30s
retries: 18
start_period: 30s
stop_grace_period: 20s
logging: *logging
zitadel-login:
image: ghcr.io/zitadel/zitadel-login:v4.19.2@sha256:ee98f024ddbebce788a7faf5a4b5571d20cbddf3bca8d3923091c424f2855737
restart: unless-stopped
init: true
user: *deploy-user
environment:
ZITADEL_API_URL: http://zitadel:8080
NEXT_PUBLIC_BASE_PATH: /ui/v2/login
ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel/bootstrap/login-client.pat
# The admin asks its sign-in page in the admin's language (ui_locales); without this an
# earlier language cookie of the login UI wins.
ZITADEL_UI_LOCALES_OVERRIDE_COOKIE: 'true'
# Zitadel picks its instance from the Host header.
CUSTOM_REQUEST_HEADERS: Host:${AUTH_DOMAIN:?set in deploy.env},X-Forwarded-Proto:https
configs:
# The image's entrypoint reads this file into its environment: the session cookie secret.
- source: login-env
target: /.env-file/.env
networks: [auth]
volumes:
- zitadel-bootstrap:/zitadel/bootstrap:ro
depends_on:
zitadel:
condition: service_healthy
healthcheck:
test: [CMD, node, /app/healthcheck.mjs, http://localhost:3000/ui/v2/login/healthy]
interval: 10s
timeout: 30s
retries: 12
start_period: 20s
stop_grace_period: 10s
logging: *logging
# The router reloads its composed config (state/router, written by router-config) by itself. It
# has no health check (the image has no shell) and exits with code 0 when it cannot start, so it
# restarts unless stopped.
router:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-router:${IMAGE_TAG:?set in deploy.env}
restart: unless-stopped
init: true
user: *deploy-user
env_file:
# ZITADEL_PROJECT_ID, the token audience.
- path: state/ids.env
required: false
environment:
# The only switch that stops the router's usage report to PostHog.
COSMO_TELEMETRY_DISABLED: 'true'
# The edge's internal key route: Zitadel's keys as the public host.
JWKS_URL: http://edge:8081/oauth/v2/keys
networks: [mesh]
volumes:
# The folder, not the file: the file is replaced by rename.
- type: bind
source: ./state/router
target: /config
read_only: true
bind:
create_host_path: false
depends_on:
edge:
condition: service_healthy
stop_grace_period: 15s
logging: *logging
# One-shot: each service's database and roles, from secrets/<svc>.env and <svc>.owner.env.
db-setup:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-db-setup:${IMAGE_TAG:?set in deploy.env}
profiles: [ops]
restart: 'no'
environment:
PGHOST: postgres
PGUSER: postgres
PGPASSWORD_FILE: /run/secrets/postgres-password
SERVICES: ${SERVICES?set in deploy.env, may be empty}
DEPLOYMENT_ENV: ${DEPLOYMENT_ENV:?set in deploy.env}
configs:
- source: postgres-password
target: /run/secrets/postgres-password
networks: [data]
volumes:
- type: bind
source: ./secrets
target: /secrets
read_only: true
bind:
create_host_path: false
depends_on:
postgres:
condition: service_healthy
logging: *logging
# One-shot: makes Zitadel match deploy.env (docs/deployment.md "Zitadel sync"), from inside the
# network with the public host header; writes state/ids.env.
zitadel-sync:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-zitadel-sync:${IMAGE_TAG:?set in deploy.env}
profiles: [ops]
restart: 'no'
user: *deploy-user
environment:
ZITADEL_API_URL: http://zitadel:8080
ZITADEL_SYNC_PAT_FILE: /zitadel/bootstrap/zitadel-sync.pat
networks: [auth]
volumes:
- ./deploy.env:/deployment/deploy.env:ro
# Its own file only, the first admins' initial passwords (ORG_<K>_ADMIN_PASSWORD): never
# secrets.env, which holds the masterkey and the database passwords.
- type: bind
source: ./secrets/zitadel-sync.env
target: /deployment/secrets/zitadel-sync.env
read_only: true
bind:
create_host_path: false
- ./state:/deployment/state
- zitadel-bootstrap:/zitadel/bootstrap:ro
depends_on:
zitadel:
condition: service_healthy
logging: *logging
# One-shot: composes the router's config for SERVICES into state/router.
router-config:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-router-config:${IMAGE_TAG:?set in deploy.env}
profiles: [ops]
restart: 'no'
user: *deploy-user
network_mode: none
volumes:
- ./deploy.env:/deployment/deploy.env:ro
- ./state:/deployment/state
logging: *logging
# ------------------------------------------------------------------------------- services ---
# Each service: itself and its migrate step, under the profile of its name (COMPOSE_PROFILES
# equals SERVICES), on port 4000, with secrets/<svc>.env; the migrate step alone gets
# secrets/<svc>.owner.env, and runs (and must succeed) before the service starts.
sample:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-sample:${IMAGE_TAG:?set in deploy.env}
profiles: [sample]
restart: unless-stopped
init: true
user: *deploy-user
env_file: *deployment-env
environment:
PORT: 4000
RELEASE: ${IMAGE_TAG:?set in deploy.env}
DATABASE_URL: postgresql://sample_app@postgres:5432/sample
SECRETS_FILE: /run/secrets/sample.env
# Zitadel's signing keys through the edge's key route: the services do not reach Zitadel.
ZITADEL_INTERNAL_URL: http://edge:8081
networks: [mesh, data]
secrets:
- source: sample-env
target: sample.env
depends_on:
postgres:
condition: service_healthy
# Never on an unmigrated database: every up runs the migrate step first.
sample-migrate:
condition: service_completed_successfully
stop_grace_period: 15s
logging: *logging
sample-migrate:
image: ${IMAGE_REGISTRY:?set in deploy.env}/opsloop-sample:${IMAGE_TAG:?set in deploy.env}
profiles: [sample]
restart: 'no'
user: *deploy-user
command: [node, node_modules/prisma/build/index.js, migrate, deploy]
environment:
MIGRATE_DATABASE_URL: postgresql://sample_owner@postgres:5432/sample
SECRETS_FILE: /run/secrets/sample.owner.env
secrets:
- source: sample-owner-env
target: sample.owner.env
healthcheck:
disable: true
networks: [data]
depends_on:
postgres:
condition: service_healthy
logging: *logging
secrets:
# A service's own files in secrets/, mode 0600: bind-mounted as they are.
sample-env:
file: ./secrets/sample.env
sample-owner-env:
file: ./secrets/sample.owner.env
# Everything from secrets.env (COMPOSE_ENV_FILES), copied into the containers that list it. These
# are configs rather than secrets: compose checks a config's ${VAR:?} when it reads the file, so a
# missing value stops `docker compose config -q`, while a secret's `environment:` source would
# start the container with an empty file. Quoted scalars carry no trailing newline (the masterkey
# must be exactly 32 bytes).
configs:
postgres-password:
content: '${POSTGRES_PASSWORD:?set in secrets.env}'
zitadel-db-admin-password:
content: '${ZITADEL_DB_ADMIN_PASSWORD:?set in secrets.env}'
zitadel-masterkey:
content: '${ZITADEL_MASTERKEY:?set in secrets.env}'
# Zitadel's database passwords (--config).
zitadel-secrets:
content: |
Database:
postgres:
User:
Password: "${ZITADEL_DB_PASSWORD:?set in secrets.env}"
Admin:
Password: "${ZITADEL_DB_ADMIN_PASSWORD:?set in secrets.env}"
# The instance admin's initial password, used once at first init (--steps).
zitadel-steps:
content: |
FirstInstance:
Org:
Human:
Password: "${ZITADEL_ADMIN_PASSWORD:?set in secrets.env}"
login-env:
content: |
ZITADEL_SESSION_COOKIE_SECRET=${LOGIN_SESSION_COOKIE_SECRET:?set in secrets.env}
# Only public and auth reach outside the host: the edge's ports and ACME, Zitadel's mail later.
networks:
public: {}
# The edge, the router and the services: /graphql, and the key route on the edge's port 8081.
mesh:
internal: true
# Zitadel, its login UI, the edge and zitadel-sync.
auth: {}
# The services' PostgreSQL, the services, their migrate steps and db-setup.
data:
internal: true
zitadel-data:
internal: true
volumes:
postgres-data:
zitadel-db-data:
zitadel-bootstrap:
edge-state:
docker compose -f oci://mayasfaraj/opsloop-compose:v0.1.0 upUse the above command to pull and run the Compose file. Learn more.
The PostgreSQL object-relational database system provides reliability and data integrity.
Pulls
1B+
Stars
15032
Last Updated
5 days