Sign inSign up

mayasfaraj/opsloop-compose:v0.1.1

Manifest digest

sha256:6b22cf6f698d19cdd2f57ec98f8f5a3c6eddd7f250901b25dcebbf4a82f332d7

Last pushed

3 days by mayasfaraj

Type

Compose

Manifest digest

sha256:6b22cf6f698d19cdd2f57ec98f8f5a3c6eddd7f250901b25dcebbf4a82f332d7

Compose file content

# 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 commands

docker compose -f oci://mayasfaraj/opsloop-compose:v0.1.1 up

Use the above command to pull and run the Compose file. Learn more⁠.

Images used

Image + 1 more

The PostgreSQL object-relational database system provides reliability and data integrity.


Pulls

1B+

Stars

15032

Last Updated

4 days