diff --git a/.gitignore b/.gitignore index 25cd298..2d28ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,16 @@ artifacts/ Thumbs.db .DS_Store -# Secrets / local +# Environment files (deploy/.env, deploy/.env.prod, web-player/.env, ...) +.env +.env.* *.env +!.env.example +!.env.prod.example -# Production env and mounted secrets -.env.prod -*.env.* +# Deploy secrets and runtime state +deploy/keys/ deploy/secrets/ +deploy/nginx/certs/ +deploy/zitadel/.output/ +deploy/.tmp/ diff --git a/deploy/.env.example b/deploy/.env.example index 373b309..c329987 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,40 +1,61 @@ -# Dev stack config (deploy/docker-compose.yml). -# Copy to deploy/.env and adjust. .env is not versioned (.gitignore ignores *.env). +# Development configuration for deploy/compose.yml + compose.dev.yml. +# Copy to deploy/.env (up.ps1 does it for you) and adjust. Not versioned. -# PostgreSQL (shared by Musify and Zitadel). Local apps use localhost:59000. +DOTNET_ENVIRONMENT=Development + +# --- Public URLs ------------------------------------------------------------- +# How each service is reached from *outside* Docker. `host.docker.internal` +# resolves to the same address from the browser, from apps running on the host +# and from inside the containers, which is what makes the OIDC issuer and the +# presigned S3 URLs valid in both run modes. +PUBLIC_WEB_URL=http://localhost:3000 +PUBLIC_API_URL=http://localhost:5111 +PUBLIC_STREAM_URL=http://localhost:8081 +PUBLIC_AUTH_URL=http://host.docker.internal:8080 + +# --- PostgreSQL (shared by Musify and Zitadel) ------------------------------- POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=musify_db POSTGRES_HOST_PORT=59000 -# pgAdmin (Postgres UI). http://localhost:5050 -PGADMIN_DEFAULT_EMAIL=admin@musify.dev -PGADMIN_DEFAULT_PASSWORD=admin -PGADMIN_HOST_PORT=5050 - -# RabbitMQ (MassTransit) -RABBITMQ_DEFAULT_USER=guest -RABBITMQ_DEFAULT_PASS=guest - -# Zitadel (OIDC). The masterkey MUST be exactly 32 characters. +# --- Zitadel (OIDC) ---------------------------------------------------------- +# The masterkey MUST be exactly 32 characters. The external domain/port form +# the token issuer, so they have to match PUBLIC_AUTH_URL. Changing them needs +# a re-init: ./up.ps1 -Destroy; ./up.ps1 ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters -# The external domain/port forms the token issuer and must match everywhere. -# host.docker.internal resolves the same from browser, local apps and containers, -# so auth works both locally and full-docker. Changing it needs a re-init -# (up.ps1 -Destroy; up.ps1). Admin login: @zitadel.. ZITADEL_EXTERNAL_DOMAIN=host.docker.internal ZITADEL_EXTERNAL_PORT=8080 +ZITADEL_EXTERNAL_SECURE=false +ZITADEL_DATABASE_PASSWORD=zitadel +# Console login: @zitadel. ZITADEL_ADMIN_USERNAME=admin ZITADEL_ADMIN_PASSWORD=Password1! -# SeaweedFS (S3). Must match deploy/seaweedfs/s3.conf. +# OIDC clients, filled in by deploy/zitadel/provision.ps1. +AUTH_CLIENT_ID= +WEB_CLIENT_ID= +WEB_CLIENT_SECRET= + +# --- RabbitMQ (MassTransit) -------------------------------------------------- +RABBITMQ_DEFAULT_USER=guest +RABBITMQ_DEFAULT_PASS=guest + +# --- SeaweedFS (S3) ---------------------------------------------------------- S3_ACCESS_KEY=admin_access_key S3_SECRET_KEY=admin_secret_key S3_BUCKET=webapi-storage +S3_PUBLIC_URL=http://host.docker.internal:8333 +S3_USE_HTTP=true -# Host paths used by up.ps1 and the apps overlay. -STREAM_KEYS_DIR=D:/weed -WORKER_TEMP_DIR=D:/tempsFilesDev +# --- Web player -------------------------------------------------------------- +# Key material for the encrypted session cookie: any long random string. +SESSION_SECRET=dev-session-secret-change-me-0123456789abcdef +# Allows the plain-HTTP Zitadel issuer in a production Node build (dev only). +AUTH_ALLOW_INSECURE_HTTP=true -# OIDC ClientId, filled in by zitadel/provision.ps1 (used by the docker path). -AUTH_CLIENT_ID= +# --- Misc -------------------------------------------------------------------- +AUTH_REQUIRE_HTTPS=false +PGADMIN_DEFAULT_EMAIL=admin@musify.dev +PGADMIN_DEFAULT_PASSWORD=admin +PGADMIN_HOST_PORT=5050 diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example new file mode 100644 index 0000000..760c7c3 --- /dev/null +++ b/deploy/.env.prod.example @@ -0,0 +1,65 @@ +# Production configuration for deploy/compose.yml + compose.prod.yml. +# Copy to deploy/.env.prod (up.sh does it for you), then: +# +# 1. replace every `example.com` with your domain, +# 2. replace every CHANGE_ME with a strong random value +# (`openssl rand -hex 24`; the Zitadel masterkey needs exactly 32 chars), +# 3. point these DNS records at the server, all proxied/TLS-terminated the +# same way: example.com, www, api, auth, stream, s3. +# +# Not versioned. Keep it readable only by the deploy user (chmod 600). + +DOTNET_ENVIRONMENT=Production + +# --- Domain ------------------------------------------------------------------ +# ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it. +ROOT_DOMAIN=example.com +PUBLIC_WEB_URL=https://example.com +PUBLIC_API_URL=https://api.example.com +PUBLIC_STREAM_URL=https://stream.example.com +PUBLIC_AUTH_URL=https://auth.example.com +S3_PUBLIC_URL=https://s3.example.com + +# --- PostgreSQL (shared by Musify and Zitadel) ------------------------------- +POSTGRES_USER=postgres +POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_DB=musify_db + +# --- Zitadel (OIDC) ---------------------------------------------------------- +# The masterkey MUST be exactly 32 characters and can never be rotated in place. +ZITADEL_MASTERKEY=CHANGE_ME_32_CHARACTERS_EXACTLY_ +ZITADEL_EXTERNAL_DOMAIN=auth.example.com +ZITADEL_EXTERNAL_PORT=443 +ZITADEL_EXTERNAL_SECURE=true +ZITADEL_DATABASE_PASSWORD=CHANGE_ME +# Console login: @zitadel. +ZITADEL_ADMIN_USERNAME=admin +ZITADEL_ADMIN_PASSWORD=CHANGE_ME + +# OIDC clients, filled in by deploy/zitadel/provision.sh. +AUTH_CLIENT_ID= +WEB_CLIENT_ID= +WEB_CLIENT_SECRET= + +# --- RabbitMQ (MassTransit) -------------------------------------------------- +RABBITMQ_DEFAULT_USER=musify +RABBITMQ_DEFAULT_PASS=CHANGE_ME + +# --- SeaweedFS (S3) ---------------------------------------------------------- +S3_ACCESS_KEY=CHANGE_ME +S3_SECRET_KEY=CHANGE_ME +S3_BUCKET=webapi-storage +S3_USE_HTTP=false + +# --- Web player -------------------------------------------------------------- +# Key material for the encrypted session cookie. Rotating it logs everyone out. +SESSION_SECRET=CHANGE_ME +AUTH_ALLOW_INSECURE_HTTP=false + +# --- Misc -------------------------------------------------------------------- +AUTH_REQUIRE_HTTPS=true +# Largest request body nginx accepts (audio uploads go through the web player). +MAX_UPLOAD_SIZE=1024m +# Extra yt-dlp flags for the Worker. The cookie jar avoids YouTube's bot checks; +# drop it here if you do not have one: deploy/secrets/ is mounted at /secrets. +YTDLP_ADDITIONAL_ARGUMENTS=--cookies /secrets/youtube_cookies.txt --extractor-retries 1 --socket-timeout 15 -N 4 diff --git a/deploy/compose.dev.yml b/deploy/compose.dev.yml new file mode 100644 index 0000000..20f281d --- /dev/null +++ b/deploy/compose.dev.yml @@ -0,0 +1,80 @@ +name: musify-dev + +# Development overlay: publishes the infrastructure ports to the host so the +# .NET apps and the web player can run from the IDE, and adds pgAdmin. +# +# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build +# +# `host.docker.internal` (see ZITADEL_EXTERNAL_DOMAIN / *_PUBLIC_URL in .env) +# resolves to the same address from the browser, from apps on the host and from +# inside the containers, so the OIDC issuer and the presigned S3 URLs are valid +# in both modes. `extra_hosts` gives Linux containers that same name. + +x-host-alias: &host-alias + - "${ZITADEL_EXTERNAL_DOMAIN}:host-gateway" + +services: + postgres: + ports: + - "${POSTGRES_HOST_PORT}:5432" + + zitadel: + ports: + - "${ZITADEL_EXTERNAL_PORT}:8080" + + rabbitmq: + ports: + - "5672:5672" # AMQP -> MassTransit + - "15672:15672" # management UI + + seaweedfs: + ports: + - "9333:9333" # master / admin UI + - "8888:8888" # filer -> StreamingGateway + - "8333:8333" # S3 API -> API, Worker and presigned uploads + + jaeger: + ports: + - "16686:16686" # UI + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + + # Optional Postgres UI: docker compose ... --profile tools up -d + pgadmin: + profiles: [tools] + image: dpage/pgadmin4:latest + container_name: musify-pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} + PGADMIN_CONFIG_SERVER_MODE: "False" + depends_on: + postgres: + condition: service_healthy + ports: + - "${PGADMIN_HOST_PORT}:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + restart: unless-stopped + + api: + ports: + - "5111:5111" + extra_hosts: *host-alias + + worker: + extra_hosts: *host-alias + + gateway: + ports: + - "8081:8081" + extra_hosts: *host-alias + + web-player: + ports: + - "3000:3000" + extra_hosts: *host-alias + +volumes: + pgadmin_data: diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml new file mode 100644 index 0000000..1241136 --- /dev/null +++ b/deploy/compose.prod.yml @@ -0,0 +1,40 @@ +name: musify-prod + +# Production overlay: adds the nginx edge and the host-mounted secrets. +# Nothing but nginx publishes a port; every other service is reachable only on +# the internal `musify-prod_default` network. +# +# docker compose --env-file deploy/.env.prod \ +# -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build +# +# Use ./up.sh, which also generates the stream keys, provisions Zitadel and +# applies the database migrations. + +services: + worker: + # yt-dlp reads the YouTube cookie jar from here (see YTDLP_ADDITIONAL_ARGUMENTS). + volumes: + - ./secrets:/secrets:ro + + nginx: + image: nginx:1.27-alpine + container_name: musify-nginx + depends_on: + - api + - gateway + - zitadel + - web-player + environment: + # The entrypoint renders /etc/nginx/templates/*.template with envsubst. + # The filter keeps it from eating nginx's own $host / $remote_addr. + MUSIFY_ROOT_DOMAIN: ${ROOT_DOMAIN} + MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m} + NGINX_ENVSUBST_FILTER: ^MUSIFY_ + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/templates:/etc/nginx/templates:ro + - ./nginx/snippets:/etc/nginx/snippets:ro + - ./nginx/certs:/etc/nginx/certs:ro + restart: unless-stopped diff --git a/deploy/compose.yml b/deploy/compose.yml new file mode 100644 index 0000000..aede0ba --- /dev/null +++ b/deploy/compose.yml @@ -0,0 +1,277 @@ +# Musify — base stack, shared by development and production. +# +# This file publishes no host ports and hardcodes no domain: everything that +# differs between environments comes from the .env file (see .env.example) or +# from the overlay that is merged on top of it. +# +# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +# prod docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d +# +# The four application services sit behind the `apps` profile, so a plain `up` +# starts only the infrastructure (the usual dev setup: apps from the IDE). +# Add `--profile apps` to run them in Docker. Use ./up.ps1 (dev) or ./up.sh +# (prod) instead of calling compose by hand. + +x-app-depends: &app-depends + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + seaweedfs: + condition: service_healthy + +# Backend settings that are identical for the API and the Worker. ASP.NET Core +# maps `__` to `:`, so these override the matching AppSettings.json sections. +x-backend-env: &backend-env + Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" + MassTransit__Address: "rabbitmq://rabbitmq:5672" + MassTransit__Username: ${RABBITMQ_DEFAULT_USER} + MassTransit__Password: ${RABBITMQ_DEFAULT_PASS} + # The public S3 URL, not the internal one: the same address is used to sign + # the presigned upload/download URLs the browser has to be able to reach. + InfrastructureStorage__Address: ${S3_PUBLIC_URL} + InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY} + InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY} + InfrastructureStorage__ForcePathStyle: "true" + InfrastructureStorage__UseHttp: ${S3_USE_HTTP} + ApplicationStorage__Bucket: ${S3_BUCKET} + OpenTelemetry__OtlpEndpoint: "http://jaeger:4317" + +services: + # --- Infrastructure ------------------------------------------------------- + + postgres: + image: postgres:17-alpine + container_name: musify-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + # OIDC provider. `start-from-init` runs init + setup (both idempotent) and + # then starts, so the same command works on a cold and on a warm volume. + # It reuses the Postgres above: the admin user creates the `zitadel` database + # and role on first start. + zitadel: + image: ghcr.io/zitadel/zitadel:latest + container_name: musify-zitadel + command: start-from-init --masterkeyFromEnv --tlsMode disabled + environment: + ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY} + # The external domain/port forms the token issuer and must be identical + # for the browser, the apps and the containers (Zitadel routes by Host). + ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNAL_SECURE} + ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN} + ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT} + ZITADEL_TLS_ENABLED: "false" + ZITADEL_DATABASE_POSTGRES_HOST: postgres + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DATABASE_PASSWORD} + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER} + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD} + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + # Initial console admin. Login: @zitadel. + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD} + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" + # Service account + PAT written on init; the provisioning script uses it + # to create the Musify project and its OIDC apps. + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" + ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat + depends_on: + postgres: + condition: service_healthy + volumes: + - ./zitadel/.output:/output + restart: unless-stopped + + rabbitmq: + image: rabbitmq:4-management-alpine + container_name: musify-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + # All-in-one object store: master + volume + filer + S3 gateway. + # The S3 identity file is rendered from the environment on start so the + # credentials can never drift from the ones handed to the apps. + seaweedfs: + image: chrislusf/seaweedfs:latest + container_name: musify-seaweedfs + entrypoint: ["/bin/sh", "-c"] + command: + - | + cat > /tmp/s3.conf <<'JSON' + { + "identities": [ + { + "name": "musify", + "credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }], + "actions": ["Admin", "Read", "Write"] + } + ] + } + JSON + exec weed server \ + -ip.bind=0.0.0.0 \ + -dir=/data \ + -master.volumeSizeLimitMB=1024 \ + -volume.max=0 \ + -filer \ + -s3 \ + -s3.config=/tmp/s3.conf + volumes: + - seaweedfs_data:/data + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + # One-shot: SeaweedFS does not auto-create buckets. + seaweedfs-init: + image: amazon/aws-cli:latest + container_name: musify-seaweedfs-init + depends_on: + seaweedfs: + condition: service_healthy + environment: + AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} + AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} + AWS_DEFAULT_REGION: us-east-1 + entrypoint: ["/bin/sh", "-c"] + command: + - | + aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true + aws --endpoint-url http://seaweedfs:8333 s3 ls + restart: "no" + + # OTLP collector + trace UI (MassTransit / EF Core / HTTP spans). + jaeger: + image: jaegertracing/all-in-one:latest + container_name: musify-jaeger + environment: + COLLECTOR_OTLP_ENABLED: "true" + restart: unless-stopped + + # --- Applications (profile: apps) ----------------------------------------- + + api: + profiles: [apps] + build: + context: ../backend + dockerfile: Musify.Api.Dockerfile + container_name: musify-api + depends_on: *app-depends + environment: + <<: *backend-env + ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT} + ASPNETCORE_URLS: http://+:5111 + StreamGateway__PublicBaseUrl: ${PUBLIC_STREAM_URL} + StreamTicket__PrivateKeyPath: /keys/stream_private.pem + Workers__Routes__TemporaryFilesDirectory: /tmp/musify + # Client id and secret are written to the .env file by the Zitadel + # provisioning script (deploy/zitadel/provision.*). + Authentication__MetadataAddress: "${PUBLIC_AUTH_URL}/.well-known/openid-configuration" + Authentication__IssuerAddress: ${PUBLIC_AUTH_URL} + Authentication__ClientId: ${AUTH_CLIENT_ID} + Authentication__AudienceAddress: ${AUTH_CLIENT_ID} + Authentication__AuthorizationEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/authorize?prompt=login" + Authentication__TokenEndpoint: "${PUBLIC_AUTH_URL}/oauth/v2/token" + Authentication__ScalarRedirectUri: "${PUBLIC_API_URL}/scalar/" + Authentication__Scopes__0: openid + Authentication__Scopes__1: profile + Authentication__Scopes__2: email + Authentication__RequireHttpsMetadata: ${AUTH_REQUIRE_HTTPS} + volumes: + - ./keys:/keys:ro + restart: unless-stopped + + worker: + profiles: [apps] + build: + context: ../backend + dockerfile: Musify.Worker.Dockerfile + container_name: musify-worker + depends_on: *app-depends + environment: + <<: *backend-env + DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT} + AudioTranscoder__Routes__WorkingDirectory: /tmp/musify + Workers__Routes__TemporaryFilesDirectory: /tmp/musify + YtDlp__AdditionalArguments: ${YTDLP_ADDITIONAL_ARGUMENTS:-} + restart: unless-stopped + + # YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and + # streams the bytes, so audio never goes through the API. + gateway: + profiles: [apps] + build: + context: ../backend + dockerfile: Musify.StreamingGateway.Dockerfile + container_name: musify-gateway + depends_on: + seaweedfs: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT} + ASPNETCORE_URLS: http://+:8081 + StreamTicket__PublicKeyPath: /keys/stream_public.pem + ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/" + ReverseProxy__Routes__media__Transforms__0__PathPattern: "/buckets/${S3_BUCKET}/{**rest}" + Cors__AllowedOrigins__0: ${PUBLIC_WEB_URL} + volumes: + - ./keys:/keys:ro + restart: unless-stopped + + web-player: + profiles: [apps] + build: + context: ../web-player + dockerfile: Dockerfile + container_name: musify-web-player + depends_on: + - api + environment: + NODE_ENV: production + PORT: 3000 + ORIGIN: ${PUBLIC_WEB_URL} + ZITADEL_ISSUER: ${PUBLIC_AUTH_URL} + ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID} + ZITADEL_CLIENT_SECRET: ${WEB_CLIENT_SECRET} + AUTH_REDIRECT_URI: "${PUBLIC_WEB_URL}/auth/callback" + AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/" + AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP} + SESSION_SECRET: ${SESSION_SECRET} + API_BASE_URL: http://api:5111 + # Presigned PUTs are signed for the public S3 host; the container sends + # them straight to SeaweedFS instead of going back out through the edge. + S3_UPLOAD_ENDPOINT: http://seaweedfs:8333 + restart: unless-stopped + +volumes: + postgres_data: + rabbitmq_data: + seaweedfs_data: diff --git a/deploy/docker-compose.apps.yml b/deploy/docker-compose.apps.yml deleted file mode 100644 index 78a6c5f..0000000 --- a/deploy/docker-compose.apps.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: musify-dev - -# Optional overlay: runs the .NET apps in Docker. Merges with docker-compose.yml. -# -# docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.apps.yml up -d --build -# -# For active development prefer infra in Docker + apps from the IDE (easier to -# debug). Auth works in both modes: ZITADEL_EXTERNAL_DOMAIN=host.docker.internal -# resolves the same from the browser, local apps and containers, so the token -# issuer matches everywhere (containers reach the host via extra_hosts). - -x-backend-depends: &backend-depends - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - seaweedfs: - condition: service_healthy - -x-backend-env: &backend-env - Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" - MassTransit__Address: "rabbitmq://rabbitmq:5672" - MassTransit__Username: ${RABBITMQ_DEFAULT_USER} - MassTransit__Password: ${RABBITMQ_DEFAULT_PASS} - InfrastructureStorage__Address: "http://seaweedfs:8333" - InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY} - InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY} - InfrastructureStorage__ForcePathStyle: "true" - InfrastructureStorage__UseHttp: "true" - ApplicationStorage__Bucket: ${S3_BUCKET} - OpenTelemetry__OtlpEndpoint: "http://jaeger:4317" - -services: - api: - build: - context: ../backend - dockerfile: Musify.Api.Dockerfile - container_name: musify-api - depends_on: *backend-depends - environment: - <<: *backend-env - ASPNETCORE_ENVIRONMENT: Development - ASPNETCORE_URLS: http://+:5111 - StreamGateway__PublicBaseUrl: "http://localhost:8081" - StreamTicket__PrivateKeyPath: "/keys/stream_private.pem" - # Auth values provisioned by zitadel/provision.ps1 (AUTH_CLIENT_ID in .env). - Authentication__MetadataAddress: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/.well-known/openid-configuration" - Authentication__IssuerAddress: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}" - Authentication__ClientId: "${AUTH_CLIENT_ID}" - Authentication__AudienceAddress: "${AUTH_CLIENT_ID}" - Authentication__AuthorizationEndpoint: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/oauth/v2/authorize?prompt=login" - Authentication__TokenEndpoint: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/oauth/v2/token" - Authentication__ScalarRedirectUri: "http://localhost:5111/scalar/" - Authentication__Scopes__0: "openid" - Authentication__Scopes__1: "profile" - Authentication__Scopes__2: "email" - Authentication__RequireHttpsMetadata: "false" - extra_hosts: - - "${ZITADEL_EXTERNAL_DOMAIN}:host-gateway" - volumes: - - "${STREAM_KEYS_DIR:-D:/weed}:/keys:ro" - ports: - - "5111:5111" - restart: unless-stopped - - worker: - build: - context: ../backend - dockerfile: Musify.Worker.Dockerfile - container_name: musify-worker - depends_on: *backend-depends - environment: - <<: *backend-env - DOTNET_ENVIRONMENT: Development - AudioTranscoder__Routes__WorkingDirectory: "/tmp/musify" - Workers__Routes__TemporaryFilesDirectory: "/tmp/musify" - restart: unless-stopped - - gateway: - build: - context: ../backend - dockerfile: Musify.StreamingGateway.Dockerfile - container_name: musify-gateway - depends_on: - seaweedfs: - condition: service_healthy - environment: - ASPNETCORE_ENVIRONMENT: Development - ASPNETCORE_URLS: http://+:8081 - StreamTicket__PublicKeyPath: "/keys/stream_public.pem" - ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/" - volumes: - - "${STREAM_KEYS_DIR:-D:/weed}:/keys:ro" - ports: - - "8081:8081" - restart: unless-stopped diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml deleted file mode 100644 index 94d362f..0000000 --- a/deploy/docker-compose.prod.yml +++ /dev/null @@ -1,273 +0,0 @@ -name: musify-prod - -# Production stack for the VPS (ikerdemo.cat). Everything runs on the internal -# `musify-prod_default` network; only nginx publishes ports to the host (80/443). -# Config in deploy/.env.prod. -# -# docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d --build - -x-backend-depends: &backend-depends - postgres: - condition: service_healthy - rabbitmq: - condition: service_healthy - seaweedfs: - condition: service_healthy - -x-backend-env: &backend-env - Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" - MassTransit__Address: "rabbitmq://rabbitmq:5672" - MassTransit__Username: ${RABBITMQ_DEFAULT_USER} - MassTransit__Password: ${RABBITMQ_DEFAULT_PASS} - InfrastructureStorage__Address: "https://s3.ikerdemo.cat" - InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY} - InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY} - InfrastructureStorage__ForcePathStyle: "true" - InfrastructureStorage__UseHttp: "false" - ApplicationStorage__Bucket: ${S3_BUCKET} - OpenTelemetry__OtlpEndpoint: "http://jaeger:4317" - -x-zitadel-db-env: &zitadel-db-env - ZITADEL_DATABASE_POSTGRES_HOST: postgres - 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_USER} - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD} - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - -services: - postgres: - image: postgres:17-alpine - container_name: musify-postgres - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - zitadel-init: - image: ghcr.io/zitadel/zitadel:latest - container_name: musify-zitadel-init - command: 'init' - environment: - <<: *zitadel-db-env - depends_on: - postgres: - condition: service_healthy - restart: "no" - - zitadel-setup: - image: ghcr.io/zitadel/zitadel:latest - container_name: musify-zitadel-setup - command: 'setup --masterkeyFromEnv --tlsMode disabled' - environment: - <<: *zitadel-db-env - ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY} - ZITADEL_EXTERNALSECURE: "true" - ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN} - ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT} - ZITADEL_TLS_ENABLED: "false" - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME} - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD} - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" - ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat - depends_on: - zitadel-init: - condition: service_completed_successfully - volumes: - - ./zitadel/.output:/output - restart: "no" - - zitadel: - image: ghcr.io/zitadel/zitadel:latest - container_name: musify-zitadel - command: 'start --masterkeyFromEnv --tlsMode disabled' - environment: - <<: *zitadel-db-env - ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY} - ZITADEL_EXTERNALSECURE: "true" - ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN} - ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT} - ZITADEL_TLS_ENABLED: "false" - depends_on: - zitadel-setup: - condition: service_completed_successfully - volumes: - - ./zitadel/.output:/output - restart: unless-stopped - - rabbitmq: - image: rabbitmq:4-management-alpine - container_name: musify-rabbitmq - environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} - volumes: - - rabbitmq_data:/var/lib/rabbitmq - healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - seaweedfs: - image: chrislusf/seaweedfs:latest - container_name: musify-seaweedfs - command: > - server - -ip.bind=0.0.0.0 - -dir=/data - -master.volumeSizeLimitMB=1024 - -volume.max=0 - -filer - -s3 - -s3.config=/etc/seaweedfs/s3.conf - volumes: - - seaweedfs_data:/data - - ./seaweedfs/s3.conf:/etc/seaweedfs/s3.conf:ro - healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - seaweedfs-init: - image: amazon/aws-cli:latest - container_name: musify-seaweedfs-init - depends_on: - seaweedfs: - condition: service_healthy - environment: - AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} - AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} - AWS_DEFAULT_REGION: us-east-1 - entrypoint: ["/bin/sh", "-c"] - command: - - | - aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true - aws --endpoint-url http://seaweedfs:8333 s3 ls - restart: "no" - - jaeger: - image: jaegertracing/all-in-one:latest - container_name: musify-jaeger - environment: - COLLECTOR_OTLP_ENABLED: "true" - restart: unless-stopped - - api: - build: - context: ../backend - dockerfile: Musify.Api.Dockerfile - container_name: musify-api - depends_on: *backend-depends - environment: - <<: *backend-env - ASPNETCORE_ENVIRONMENT: Production - ASPNETCORE_URLS: http://+:5111 - StreamGateway__PublicBaseUrl: "https://${STREAM_DOMAIN}" - StreamTicket__PrivateKeyPath: "/keys/stream_private.pem" - Authentication__MetadataAddress: "https://${AUTH_DOMAIN}/.well-known/openid-configuration" - Authentication__IssuerAddress: "https://${AUTH_DOMAIN}" - Authentication__ClientId: "${AUTH_CLIENT_ID}" - Authentication__AudienceAddress: "${AUTH_CLIENT_ID}" - Authentication__AuthorizationEndpoint: "https://${AUTH_DOMAIN}/oauth/v2/authorize?prompt=login" - Authentication__TokenEndpoint: "https://${AUTH_DOMAIN}/oauth/v2/token" - Authentication__ScalarRedirectUri: "https://${API_DOMAIN}/scalar/" - Authentication__Scopes__0: "openid" - Authentication__Scopes__1: "profile" - Authentication__Scopes__2: "email" - Authentication__RequireHttpsMetadata: "true" - YtDlp__AdditionalArguments: "--cookies /secrets/youtube_cookies.txt --extractor-retries 1 --socket-timeout 15" - volumes: - - ./keys:/keys:ro - - ./secrets/youtube_cookies.txt:/secrets/youtube_cookies.txt - restart: unless-stopped - - worker: - build: - context: ../backend - dockerfile: Musify.Worker.Dockerfile - container_name: musify-worker - depends_on: *backend-depends - environment: - <<: *backend-env - DOTNET_ENVIRONMENT: Production - AudioTranscoder__Routes__WorkingDirectory: "/tmp/musify" - Workers__Routes__TemporaryFilesDirectory: "/tmp/musify" - YtDlp__AdditionalArguments: "--cookies /secrets/youtube_cookies.txt --extractor-retries 1 --socket-timeout 15 -N 4" - volumes: - - ./secrets/youtube_cookies.txt:/secrets/youtube_cookies.txt - restart: unless-stopped - - gateway: - build: - context: ../backend - dockerfile: Musify.StreamingGateway.Dockerfile - container_name: musify-gateway - depends_on: - seaweedfs: - condition: service_healthy - environment: - ASPNETCORE_ENVIRONMENT: Production - ASPNETCORE_URLS: http://+:8081 - StreamTicket__PublicKeyPath: "/keys/stream_public.pem" - ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/" - volumes: - - ./keys:/keys:ro - restart: unless-stopped - - web-player: - build: - context: ../web-player - dockerfile: Dockerfile - container_name: musify-web-player - depends_on: - - api - environment: - NODE_ENV: production - ORIGIN: "https://${WEB_DOMAIN}" - ZITADEL_ISSUER: "https://${AUTH_DOMAIN}" - ZITADEL_CLIENT_ID: "${WEB_CLIENT_ID}" - ZITADEL_CLIENT_SECRET: "${WEB_CLIENT_SECRET}" - AUTH_REDIRECT_URI: "https://${WEB_DOMAIN}/auth/callback" - AUTH_POST_LOGOUT_URI: "https://${WEB_DOMAIN}/" - SESSION_SECRET: "${SESSION_SECRET}" - API_BASE_URL: "http://api:5111" - restart: unless-stopped - - nginx: - image: nginx:1.27-alpine - container_name: musify-nginx - depends_on: - - api - - gateway - - zitadel - - web-player - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx/conf.d:/etc/nginx/conf.d:ro - - ./nginx/certs:/etc/nginx/certs:ro - restart: unless-stopped - -volumes: - postgres_data: - rabbitmq_data: - seaweedfs_data: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index 0205587..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -1,163 +0,0 @@ -name: musify-dev - -# Infrastructure stack for Musify development: everything the backend needs -# (Postgres, Zitadel, RabbitMQ, SeaweedFS + bucket, Jaeger/OTLP). The .NET apps -# run from the IDE and connect over the published ports. Config in deploy/.env -# -# docker compose -f deploy/docker-compose.yml up -d - -services: - postgres: - image: postgres:17-alpine - container_name: musify-postgres - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ports: - - "${POSTGRES_HOST_PORT}:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - pgadmin: - image: dpage/pgadmin4:latest - container_name: musify-pgadmin - environment: - PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} - PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} - PGADMIN_CONFIG_SERVER_MODE: "False" - depends_on: - postgres: - condition: service_healthy - ports: - - "${PGADMIN_HOST_PORT}:80" - volumes: - - pgadmin_data:/var/lib/pgadmin - restart: unless-stopped - - zitadel: - image: ghcr.io/zitadel/zitadel:latest - container_name: musify-zitadel - command: 'start-from-init --masterkeyFromEnv --tlsMode disabled' - environment: - ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY} - ZITADEL_EXTERNALSECURE: "false" - ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN} - ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT} - ZITADEL_TLS_ENABLED: "false" - # Reuses the same Postgres: the admin user creates the 'zitadel' database - # and the 'zitadel' user on first start. - ZITADEL_DATABASE_POSTGRES_HOST: postgres - 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_USER} - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD} - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - # Initial console admin. Login: @zitadel. - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME} - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD} - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false" - # Service account + PAT for automatic provisioning. Zitadel writes the PAT - # to PATPATH on init; provision.ps1 uses it to create the project and app. - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z" - ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat - depends_on: - postgres: - condition: service_healthy - ports: - - "${ZITADEL_EXTERNAL_PORT}:8080" - volumes: - - ./zitadel/.output:/output - restart: unless-stopped - - rabbitmq: - image: rabbitmq:4-management-alpine - container_name: musify-rabbitmq - environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} - ports: - - "5672:5672" # AMQP -> MassTransit (API + Worker) - - "15672:15672" # management UI - volumes: - - rabbitmq_data:/var/lib/rabbitmq - healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - seaweedfs: - image: chrislusf/seaweedfs:latest - container_name: musify-seaweedfs - # All-in-one dev server: master + volume + filer + S3 gateway. - command: > - server - -ip.bind=0.0.0.0 - -dir=/data - -master.volumeSizeLimitMB=1024 - -volume.max=0 - -filer - -s3 - -s3.config=/etc/seaweedfs/s3.conf - ports: - - "9333:9333" # master (admin/UI) - - "8888:8888" # filer HTTP -> StreamingGateway - - "8333:8333" # S3 API -> WebApi/Worker (InfrastructureStorage:Address) - volumes: - - seaweedfs_data:/data - - ./seaweedfs/s3.conf:/etc/seaweedfs/s3.conf:ro - healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"] - interval: 10s - timeout: 5s - retries: 10 - restart: unless-stopped - - # One-shot: creates the app bucket in SeaweedFS (it is not auto-created). - seaweedfs-init: - image: amazon/aws-cli:latest - container_name: musify-seaweedfs-init - depends_on: - seaweedfs: - condition: service_healthy - environment: - AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY} - AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY} - AWS_DEFAULT_REGION: us-east-1 - entrypoint: ["/bin/sh", "-c"] - command: - - | - aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true - aws --endpoint-url http://seaweedfs:8333 s3 ls - restart: "no" - - # OTLP collector + trace UI (MassTransit/EF/HTTP spans). OtlpEndpoint = :4317 - jaeger: - image: jaegertracing/all-in-one:latest - container_name: musify-jaeger - environment: - COLLECTOR_OTLP_ENABLED: "true" - ports: - - "16686:16686" # UI - - "4317:4317" # OTLP gRPC - - "4318:4318" # OTLP HTTP - restart: unless-stopped - -volumes: - postgres_data: - pgadmin_data: - rabbitmq_data: - seaweedfs_data: diff --git a/deploy/seaweedfs/s3.conf b/deploy/seaweedfs/s3.conf deleted file mode 100644 index dc0ed2a..0000000 --- a/deploy/seaweedfs/s3.conf +++ /dev/null @@ -1,24 +0,0 @@ -{ - "identities": [ - { - "name": "admin_user", - "credentials": [ - { - "accessKey": "admin_access_key", - "secretKey": "admin_secret_key" - } - ], - "actions": ["Admin", "Read", "Write"] - }, - { - "name": "read_only_user", - "credentials": [ - { - "accessKey": "readonly_access_key", - "secretKey": "readonly_secret_key" - } - ], - "actions": ["Read"] - } - ] -} diff --git a/deploy/zitadel/.gitignore b/deploy/zitadel/.gitignore deleted file mode 100644 index cb93576..0000000 --- a/deploy/zitadel/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Secrets Zitadel writes on init (do not version) -.output/