@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
483 lines (460 loc) • 18.4 kB
YAML
# Mesh full-local platform (`mesh start`).
#
# Shipped inside @mesh-tech/mesh-cli so any repo can run the platform locally
# with only Docker installed — no AWS credentials, no VPN, no Pulumi state.
# Evolved from tools/local/docker-compose.yml in mesh-platform; every image is
# pullable (no monorepo build contexts). The codec server and Temporal UI
# gateway require monorepo image builds and join the stack once their images
# are published.
#
# Discovery fabric: ministack (MIT-licensed AWS emulator) provides SSM
# Parameter Store + Secrets Manager + S3 so the registry
# (/mesh-platform/{tenant}/{env}/…) works locally with unchanged code paths —
# services point at it via the SDK-native AWS_ENDPOINT_URL_* env overrides.
# `mesh start` seeds tenant `local` / env `dev`.
#
# SECURITY: every credential in this stack (postgres/postgres, test/test AWS
# keys, the Zitadel masterkey, SpiceDB's local-dev-key, permissive CORS) is a
# LOCAL-ONLY default. Never reuse any of them outside this compose project —
# deployed platforms mint their own secrets. Data/secret ports are bound to
# 127.0.0.1 so none of this is reachable from the LAN.
name: mesh-local
# AWS wiring for in-network services (Hub API/worker join the stack once
# their images are published): merge this anchor into a service's environment
# and its AWS SDK clients hit the local fabric with zero code changes —
# AWS_ENDPOINT_URL is the SDK-native endpoint override.
x-local-aws-env: &local-aws-env
AWS_ENDPOINT_URL: http://ministack:4566
AWS_REGION: us-east-2
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
services:
database:
image: postgres:17@sha256:a426e44bac0b759c95894d68e1a0ac03ecc20b619f498a91aae373bf06d8508d
ports:
- '127.0.0.1:5433:5432'
environment:
PGUSER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: temporal
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d
networks:
- mesh_network
healthcheck:
# Single-string CMD-SHELL (an array's extra elements are ignored shell
# positionals) probing over TCP: the entrypoint's temporary initdb-phase
# server is socket-only, so -h 127.0.0.1 can't go green until the final
# server is up — after /docker-entrypoint-initdb.d has created the
# spicedb/app/hub databases spicedb-migrate needs.
test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -d temporal -U postgres']
interval: '10s'
timeout: '30s'
retries: 5
start_period: '20s'
memcached:
image: memcached:1.6-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
ports:
- '11211:11211'
networks:
- mesh_network
command: -m 128
healthcheck:
test: ['CMD', 'nc', '-z', 'localhost', '11211']
interval: 30s
timeout: 5s
retries: 3
elasticsearch:
image: opensearchproject/opensearch:2.11.1
ports:
- '9200:9200'
environment:
- cluster.name=temporal-elasticsearch
- discovery.type=single-node
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m"
- DISABLE_INSTALL_DEMO_CONFIG=true
- DISABLE_SECURITY_PLUGIN=true
- compatibility.override_main_response_version=true
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data
networks:
- mesh_network
healthcheck:
# URL must be quoted: an unquoted & backgrounds curl and the check
# always passes, letting Temporal start before OpenSearch is up.
test: ['CMD-SHELL', 'curl -f "http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=10s" || exit 1']
interval: 30s
timeout: 10s
retries: 5
temporal:
image: temporalio/auto-setup:1.28.1
depends_on:
database:
condition: service_healthy
elasticsearch:
condition: service_healthy
environment:
- DB=postgres12
- DB_PORT=5432
- POSTGRES_USER=postgres
- POSTGRES_PWD=postgres
- POSTGRES_SEEDS=database
- DYNAMIC_CONFIG_FILE_PATH=/etc/temporal/config/dynamicconfig/local.yaml
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_CLI_ADDRESS=temporal:7233
- ENABLE_ES=true
- ES_SEEDS=elasticsearch
- ES_PORT=9200
- ES_VERSION=v7
- ES_VIS_INDEX=temporal_visibility_v1_dev
ports:
- '7233:7233'
volumes:
# Mount ONLY the dynamic config — mounting over /etc/temporal/config
# would clobber the auto-setup image's config_template.yaml.
- ./temporal/local.yaml:/etc/temporal/config/dynamicconfig/local.yaml
networks:
- mesh_network
temporal-admin-tools:
image: temporalio/admin-tools:1.13.4
depends_on:
- temporal
environment:
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_CLI_ADDRESS=temporal:7233
networks:
- mesh_network
stdin_open: true
tty: true
temporal-ui:
image: temporalio/ui:2.16.2
depends_on:
- temporal
environment:
- TEMPORAL_ADDRESS=temporal:7233
- TEMPORAL_CORS_ORIGINS=http://localhost:3000,http://localhost:8233,http://localhost:8080
# Land on the seeded local tenant namespace ({tenant}-{env}).
- TEMPORAL_DEFAULT_NAMESPACE=local-dev
# Auth comes back with the seeded local Zitadel (mesh start seeds an
# org + CLI app + test users in a follow-up; until then the UI is open).
- TEMPORAL_AUTH_ENABLED=false
ports:
# 8233 = Temporal's conventional dev-UI port. Deliberately NOT 8081:
# the legacy tools/local stack serves its UI there, and a shared origin
# means shared browser localStorage — the UI would auto-redirect to
# namespaces remembered from the old stack ("namespace not found").
- '8233:8080'
networks:
- mesh_network
# Zitadel Database
zitadel-db:
image: postgres:17@sha256:a426e44bac0b759c95894d68e1a0ac03ecc20b619f498a91aae373bf06d8508d
ports:
- '127.0.0.1:5435:5432'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: zitadel
volumes:
- zitadel_postgres_data:/var/lib/postgresql/data
networks:
- mesh_network
healthcheck:
# -h 127.0.0.1 (TCP) so the socket-only initdb-phase server can't
# satisfy the probe before first-boot init completes.
test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -d zitadel -U postgres']
interval: '10s'
timeout: '30s'
retries: 5
start_period: '20s'
# One-shot: the machinekey volume must be writable by the zitadel user
# (uid 1000 — the image runs non-root and a fresh named volume is
# root-owned; the first-instance PAT write fails otherwise).
zitadel-machinekey-init:
image: busybox:1.36
command: chown -R 1000:1000 /machinekey
restart: 'no'
volumes:
- zitadel_machinekey:/machinekey
networks:
- mesh_network
# Zitadel Identity Server — v4 to match the cloud (approved tenant-auth
# pattern spec item 7: v4's no-role-claims machine tokens make version
# parity matter for auth testing).
# Local mailbox. Zitadel sends real mail for user activation and password
# resets, and without somewhere to send it those flows cannot be exercised at
# all locally — the Hub's "resend invitation" and "send password reset email"
# actions return 200 and the mail goes nowhere. Mailpit accepts everything on
# SMTP :1025 and shows it at :8025; nothing leaves the machine.
mailpit:
image: axllent/mailpit:v1.21
ports:
- '127.0.0.1:1025:1025' # SMTP, for Zitadel
- '127.0.0.1:8025:8025' # web UI, for a human
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 'true'
MP_SMTP_AUTH_ALLOW_INSECURE: 'true'
healthcheck:
test: ['CMD', '/mailpit', 'readyz']
interval: 5s
timeout: 3s
retries: 20
networks:
- mesh_network
zitadel:
image: ghcr.io/zitadel/zitadel:v4.15.3
depends_on:
zitadel-db:
condition: 'service_healthy'
zitadel-machinekey-init:
condition: 'service_completed_successfully'
mailpit:
condition: 'service_healthy'
ports:
- '127.0.0.1:8080:8080'
environment:
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_PASSWORD: zitadel
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_EXTERNALSECURE: false
ZITADEL_EXTERNALDOMAIN: localhost
ZITADEL_EXTERNALPORT: 8080
ZITADEL_HTTP_CORS_ALLOWORIGIN: "*"
ZITADEL_HTTP_CORS_ALLOWHEADERS: "*"
ZITADEL_HTTP_CORS_ALLOWMETHODS: "*"
ZITADEL_HTTP_CORS_ALLOWCREDENTIALS: true
ZITADEL_HTTP_CSRF_DISABLED: true
# v4 defaults new instances to the login-v2 UI, which is a SEPARATE
# container this stack doesn't run — without this, the authorize flow
# redirects to /ui/v2/login and dead-ends. Keep the built-in v1 login.
# (The seeder also flips the instance feature via API, belt-and-braces.)
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: false
# Platform org (approved Zitadel tenant-auth pattern): `mesh` is the
# home of platform-layer creds (mesh-cli project, test users; Hub when
# its images land) — same org name as the cloud platform tenant. App
# tenants get their own orgs, auto-created by `mesh dev --local`
# (ZitadelTenantIdentity parity); an app with no `mesh:tenant` config
# lands in the default app-tenant org `local`.
ZITADEL_FIRSTINSTANCE_ORG_NAME: mesh
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin@local.mesh
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: LocalDev1!
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
# Bootstrap machine user for the `mesh start` seeder: its PAT lands on
# the machinekey volume and the CLI reads it (docker compose cp) to
# create the Platform project, Mesh CLI OIDC app, and test users.
# First-instance settings only apply on the FIRST init — a pre-existing
# zitadel volume needs `mesh stop --destroy` to pick this up.
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: local-seeder
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Local Seeder
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: '2099-01-01T00:00:00Z'
ZITADEL_FIRSTINSTANCE_PATPATH: /machinekey/pat.txt
# SMTP for the default instance, pointed at the local mailbox above.
# DEFAULTINSTANCE settings apply at FIRST INIT only, same as the
# FIRSTINSTANCE block — a pre-existing zitadel volume keeps whatever it
# was initialised with, so `mesh start` also reconciles this over the
# admin API on every run (see seed-zitadel.ts). The env is here so a
# fresh `mesh start` has working mail before the seeder even runs.
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST: 'mailpit:1025'
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_USER: ''
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_PASSWORD: ''
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_TLS: false
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROM: 'no-reply@local.mesh'
ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROMNAME: 'Mesh (local)'
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --init-projections=true'
volumes:
- zitadel_machinekey:/machinekey
networks:
- mesh_network
# SpiceDB migration (runs to completion before spicedb serves)
spicedb-migrate:
image: authzed/spicedb:v1.42.1
command: datastore migrate head
restart: 'no'
depends_on:
database:
condition: service_healthy
environment:
SPICEDB_DATASTORE_ENGINE: postgres
SPICEDB_DATASTORE_CONN_URI: postgres://postgres:postgres@database:5432/spicedb?sslmode=disable
networks:
- mesh_network
# SpiceDB authorization engine
spicedb:
image: authzed/spicedb:v1.42.1
command: serve
depends_on:
spicedb-migrate:
condition: service_completed_successfully
environment:
SPICEDB_GRPC_PRESHARED_KEY: local-dev-key
SPICEDB_DATASTORE_ENGINE: postgres
SPICEDB_DATASTORE_CONN_URI: postgres://postgres:postgres@database:5432/spicedb?sslmode=disable
SPICEDB_HTTP_ENABLED: true
ports:
- '127.0.0.1:50051:50051'
- '127.0.0.1:8443:8443'
networks:
- mesh_network
healthcheck:
test: ['CMD', 'grpc_health_probe', '-addr=localhost:50051']
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# ministack — the local AWS fabric (SSM Parameter Store + Secrets Manager +
# S3, all services on one port). MIT-licensed LocalStack replacement (design
# §2.5). Point AWS SDK clients at it via AWS_ENDPOINT_URL=http://localhost:4566.
#
# State persists across restarts (PERSIST_STATE saves to the volume on
# graceful shutdown, reloads on boot): the registry and app secrets that
# `mesh dev` provisions live ONLY here, so without this a Docker Desktop
# restart silently wiped every provisioned app while its services kept
# running stale. `mesh start` still re-seeds platform state idempotently;
# `mesh stop --destroy` removes the volume like every other data volume.
ministack:
image: ministackorg/ministack:1.4.1
ports:
- '127.0.0.1:4566:4566'
environment:
MINISTACK_REGION: us-east-2
PERSIST_STATE: '1'
STATE_DIR: /var/lib/ministack-state
volumes:
- ministack_state:/var/lib/ministack-state
networks:
- mesh_network
healthcheck:
# Pure-Python probe — the ministack image ships no curl/wget.
test: ['CMD', 'python', '-c', "import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')"]
interval: 10s
timeout: 5s
retries: 10
start_period: 5s
# StackPort — local AWS console over the ministack fabric (MIT). Browse and
# inspect the registry (SSM), Secrets Manager, and S3 artifacts visually;
# E4's terminal complement until the local Hub lands. First consumer of the
# x-local-aws-env anchor. Host 4567 (container serves on 8080 = Zitadel's
# host port).
stackport:
image: davireis/stackport:0.3.4
ports:
- '127.0.0.1:4567:8080'
environment: *local-aws-env
depends_on:
ministack:
condition: service_healthy
networks:
- mesh_network
# ── Observability — the SAME backends the hosted platform runs ──────────
# OTel collector → Loki (logs, OTLP) + Tempo (traces, OTLP gRPC), Prometheus
# scraping the collector's metrics exporter. hub-api's LOKI_URL / TEMPO_URL /
# PROMETHEUS_URL point here, so the Hub's logs/traces/metrics views run the
# exact hosted code paths. `mesh dev --local` service logs are tailed from
# ${MESH_LOCAL_LOGS} (written via tmux pipe-pane) and labeled with the same
# k8s_namespace_name/k8s_deployment_name scheme the in-cluster
# k8sattributes processor produces.
loki:
image: grafana/loki:3.4.2
command: -config.file=/etc/loki/local-config.yaml
ports:
- '3100:3100'
volumes:
# The image's default local-config keeps all state under /tmp/loki.
- loki_data:/tmp/loki
networks:
- mesh_network
tempo:
image: grafana/tempo:2.7.1
command: -config.file=/etc/tempo/tempo.yaml
ports:
- '3200:3200'
volumes:
- ./observability/tempo.yaml:/etc/tempo/tempo.yaml
- tempo_data:/var/tempo
networks:
- mesh_network
prometheus:
image: prom/prometheus:v3.2.1
ports:
- '9090:9090'
volumes:
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml
# External-service probe targets (blackbox file_sd) — written by
# `mesh dev --externals` / ExternalService wiring, same labels the hosted
# Probe CRs carry (type/tenant/env/app/external_service).
- ${MESH_LOCAL_PROBES:-/tmp/mesh-local/probes}:/etc/prometheus/probes
- prometheus_data:/prometheus
networks:
- mesh_network
# Blackbox exporter — the same prober the hosted monitoring stack runs for
# ExternalService health checks; Prometheus drives it via the
# external-services file_sd job.
blackbox-exporter:
image: prom/blackbox-exporter:v0.25.0
command: --config.file=/etc/blackbox/blackbox.yml
ports:
- '9115:9115'
networks:
- mesh_network
volumes:
# IPv4-pinned http_2xx/tcp_connect modules — host.docker.internal's
# IPv6 ULA is unreachable from the container network.
- ./observability/blackbox.yml:/etc/blackbox/blackbox.yml
extra_hosts:
# Probe targets run on the HOST (mesh dev services/mocks).
- 'host.docker.internal:host-gateway'
otel-collector:
image: otel/opentelemetry-collector-contrib:0.121.0
command: --config=/etc/otelcol/config.yaml
ports:
- '4317:4317' # OTLP gRPC (apps: OTEL_EXPORTER_OTLP_ENDPOINT)
- '4318:4318' # OTLP HTTP
- '13133:13133' # health_check extension (mesh status probe)
volumes:
- ./observability/otel-collector.yaml:/etc/otelcol/config.yaml
# mesh dev --local writes {namespace}/{service}.log here (the CLI passes
# MESH_LOCAL_LOGS on every compose call; the fallback keeps raw
# `docker compose` usable).
- ${MESH_LOCAL_LOGS:-/tmp/mesh-local/logs}:/var/log/mesh-apps:ro
depends_on:
- loki
- tempo
networks:
- mesh_network
healthcheck:
test: ['CMD', '/otelcol-contrib', '--version']
interval: 30s
timeout: 5s
retries: 3
volumes:
postgres_data:
driver: local
ministack_state:
driver: local
elasticsearch_data:
driver: local
zitadel_postgres_data:
driver: local
zitadel_machinekey:
driver: local
loki_data:
driver: local
tempo_data:
driver: local
prometheus_data:
driver: local
networks:
mesh_network:
driver: bridge