feat(deploy): add production stack for the VPS
Compose file for ikerdemo.cat with nginx as TLS terminator, the three .NET services, Zitadel and the storage stack. Values come from deploy/.env.prod (untracked). Adds the frontend image, the migration script and the Zitadel provisioning for production.
This commit is contained in:
@@ -0,0 +1,273 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ../frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: musify-frontend
|
||||||
|
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
|
||||||
|
- frontend
|
||||||
|
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:
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Cloudflare (Full strict) terminates the public edge; this box only needs to
|
||||||
|
# trust the Cloudflare Origin Certificate. Each server block proxies to a
|
||||||
|
# container by its Docker Compose service name.
|
||||||
|
#
|
||||||
|
# proxy_pass targets use a variable + the embedded Docker DNS resolver so nginx
|
||||||
|
# re-resolves the service name on every request instead of caching the first
|
||||||
|
# container IP (which goes stale whenever a service is recreated/restarted).
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.ikerdemo.cat stream.ikerdemo.cat auth.ikerdemo.cat s3.ikerdemo.cat ikerdemo.cat www.ikerdemo.cat;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name api.ikerdemo.cat;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $upstream http://api:5111;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name stream.ikerdemo.cat;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $upstream http://gateway:8081;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name auth.ikerdemo.cat;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $upstream http://zitadel:8080;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
grpc_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name s3.ikerdemo.cat;
|
||||||
|
client_max_body_size 1024m;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $upstream http://seaweedfs:8333;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name www.ikerdemo.cat;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
|
|
||||||
|
return 301 https://ikerdemo.cat$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name ikerdemo.cat;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $upstream http://frontend:3000;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Applies EF Core migrations to the prod Postgres, from a throwaway SDK
|
||||||
|
# container attached to the compose network (postgres is not published to the host).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DEPLOY_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
BACKEND_DIR="$(CDPATH= cd -- "$DEPLOY_DIR/../backend" && pwd)"
|
||||||
|
ENV_FILE="$DEPLOY_DIR/.env.prod"
|
||||||
|
|
||||||
|
. "$ENV_FILE"
|
||||||
|
|
||||||
|
NETWORK="musify-prod_default"
|
||||||
|
CONN="Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
-v "$BACKEND_DIR":/src \
|
||||||
|
-w /src/Musify.Infrastructure \
|
||||||
|
mcr.microsoft.com/dotnet/sdk:10.0 \
|
||||||
|
sh -c "dotnet tool install --global dotnet-ef >/dev/null 2>&1 || true; \
|
||||||
|
export PATH=\$PATH:/root/.dotnet/tools; \
|
||||||
|
dotnet restore Musify.Infrastructure.csproj; \
|
||||||
|
dotnet user-secrets set 'Database:ConnectionString' '$CONN' >/dev/null; \
|
||||||
|
dotnet ef database update --context Database"
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Provisions Zitadel for Musify in production (idempotent).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Same idea as provision.ps1 but for the VPS deploy: creates the project, the
|
||||||
|
API OIDC app (PKCE, JWT) and the Web OIDC app (confidential, JWT), then
|
||||||
|
wires the values into deploy/.env.prod. Run from deploy/zitadel/ on the VPS
|
||||||
|
after `docker compose ... up -d` has brought zitadel up.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ProjectName = "Musify",
|
||||||
|
[string]$ApiAppName = "Musify API",
|
||||||
|
[string]$WebAppName = "Musify Web"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$deployDir = Split-Path $PSScriptRoot -Parent
|
||||||
|
$envPath = Join-Path $deployDir ".env.prod"
|
||||||
|
$patFile = Join-Path $PSScriptRoot ".output/admin-sa.pat"
|
||||||
|
|
||||||
|
function Read-DotEnv($path) {
|
||||||
|
$map = @{}
|
||||||
|
Get-Content $path | ForEach-Object {
|
||||||
|
if ($_ -match '^\s*#' -or $_ -notmatch '=') { return }
|
||||||
|
$k, $v = $_.Split('=', 2)
|
||||||
|
$map[$k.Trim()] = $v.Trim()
|
||||||
|
}
|
||||||
|
return $map
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = Read-DotEnv $envPath
|
||||||
|
$issuer = "https://$($cfg['AUTH_DOMAIN'])"
|
||||||
|
$apiUrl = "https://$($cfg['API_DOMAIN'])"
|
||||||
|
$webUrl = "https://$($cfg['WEB_DOMAIN'])"
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddMinutes(3)
|
||||||
|
while (-not (Test-Path $patFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 }
|
||||||
|
if (-not (Test-Path $patFile)) { throw "PAT not found ($patFile). Check 'docker compose logs zitadel'." }
|
||||||
|
|
||||||
|
$headers = @{ Authorization = "Bearer $((Get-Content $patFile -Raw).Trim())" }
|
||||||
|
$mgmt = "$issuer/management/v1"
|
||||||
|
|
||||||
|
function Invoke-Zitadel($method, $path, $body) {
|
||||||
|
$params = @{ Method = $method; Uri = "$mgmt$path"; Headers = $headers; ContentType = "application/json"; TimeoutSec = 20 }
|
||||||
|
if ($body) { $params.Body = ($body | ConvertTo-Json -Depth 10) }
|
||||||
|
Invoke-RestMethod @params -SkipCertificateCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Waiting for Zitadel at $issuer"
|
||||||
|
$deadline = (Get-Date).AddMinutes(2)
|
||||||
|
do {
|
||||||
|
try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 -SkipCertificateCheck | Out-Null; $ready = $true }
|
||||||
|
catch { $ready = $false; Start-Sleep 3 }
|
||||||
|
} until ($ready -or (Get-Date) -gt $deadline)
|
||||||
|
if (-not $ready) { throw "Zitadel did not respond in time." }
|
||||||
|
|
||||||
|
Write-Host "Disabling required Login UI v2 (use built-in /ui/login)"
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers `
|
||||||
|
-ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 -SkipCertificateCheck | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Host " warning: could not update loginV2 feature: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Project '$ProjectName'"
|
||||||
|
$project = (Invoke-Zitadel POST "/projects/_search" @{
|
||||||
|
queries = @(@{ nameQuery = @{ name = $ProjectName; method = "TEXT_QUERY_METHOD_EQUALS" } })
|
||||||
|
}).result | Where-Object { $_.name -eq $ProjectName } | Select-Object -First 1
|
||||||
|
|
||||||
|
if ($project) {
|
||||||
|
$projectId = $project.id
|
||||||
|
Write-Host " reused (id $projectId)"
|
||||||
|
} else {
|
||||||
|
$projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id
|
||||||
|
Write-Host " created (id $projectId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "OIDC app '$ApiAppName' (public, PKCE)"
|
||||||
|
$apiApp = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
|
||||||
|
queries = @(@{ nameQuery = @{ name = $ApiAppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
|
||||||
|
}).result | Where-Object { $_.name -eq $ApiAppName } | Select-Object -First 1
|
||||||
|
|
||||||
|
if ($apiApp) {
|
||||||
|
$apiClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($apiApp.id)" $null).app.oidcConfig.clientId
|
||||||
|
Write-Host " reused (clientId $apiClientId)"
|
||||||
|
} else {
|
||||||
|
$apiClientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
|
||||||
|
name = $ApiAppName
|
||||||
|
redirectUris = @("$apiUrl/scalar/", "$apiUrl/scalar/oauth2-redirect.html")
|
||||||
|
postLogoutRedirectUris = @("$apiUrl/scalar/")
|
||||||
|
responseTypes = @("OIDC_RESPONSE_TYPE_CODE")
|
||||||
|
grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE")
|
||||||
|
appType = "OIDC_APP_TYPE_USER_AGENT"
|
||||||
|
authMethodType = "OIDC_AUTH_METHOD_TYPE_NONE"
|
||||||
|
version = "OIDC_VERSION_1_0"
|
||||||
|
devMode = $false
|
||||||
|
accessTokenType = "OIDC_TOKEN_TYPE_JWT"
|
||||||
|
accessTokenRoleAssertion = $true
|
||||||
|
idTokenRoleAssertion = $true
|
||||||
|
idTokenUserinfoAssertion = $true
|
||||||
|
}).clientId
|
||||||
|
Write-Host " created (clientId $apiClientId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "OIDC app '$WebAppName' (confidential)"
|
||||||
|
$webApp = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
|
||||||
|
queries = @(@{ nameQuery = @{ name = $WebAppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
|
||||||
|
}).result | Where-Object { $_.name -eq $WebAppName } | Select-Object -First 1
|
||||||
|
|
||||||
|
$webClientSecret = $null
|
||||||
|
if ($webApp) {
|
||||||
|
$webClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($webApp.id)" $null).app.oidcConfig.clientId
|
||||||
|
Write-Host " reused (clientId $webClientId, secret NOT regenerated)"
|
||||||
|
} else {
|
||||||
|
$created = Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
|
||||||
|
name = $WebAppName
|
||||||
|
redirectUris = @("$webUrl/auth/callback")
|
||||||
|
postLogoutRedirectUris = @("$webUrl/")
|
||||||
|
responseTypes = @("OIDC_RESPONSE_TYPE_CODE")
|
||||||
|
grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE")
|
||||||
|
appType = "OIDC_APP_TYPE_WEB"
|
||||||
|
authMethodType = "OIDC_AUTH_METHOD_TYPE_BASIC"
|
||||||
|
version = "OIDC_VERSION_1_0"
|
||||||
|
devMode = $false
|
||||||
|
accessTokenType = "OIDC_TOKEN_TYPE_JWT"
|
||||||
|
accessTokenRoleAssertion = $true
|
||||||
|
idTokenRoleAssertion = $true
|
||||||
|
idTokenUserinfoAssertion = $true
|
||||||
|
}
|
||||||
|
$webClientId = $created.clientId
|
||||||
|
$webClientSecret = $created.clientSecret
|
||||||
|
Write-Host " created (clientId $webClientId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$envLines = Get-Content $envPath
|
||||||
|
function Set-EnvValue([string[]]$lines, [string]$key, [string]$value) {
|
||||||
|
if ($lines -match "^\s*$key=") {
|
||||||
|
return $lines -replace "^\s*$key=.*", "$key=$value"
|
||||||
|
}
|
||||||
|
return $lines + "$key=$value"
|
||||||
|
}
|
||||||
|
$envLines = Set-EnvValue $envLines "AUTH_CLIENT_ID" $apiClientId
|
||||||
|
$envLines = Set-EnvValue $envLines "WEB_CLIENT_ID" $webClientId
|
||||||
|
if ($webClientSecret) {
|
||||||
|
$envLines = Set-EnvValue $envLines "WEB_CLIENT_SECRET" $webClientSecret
|
||||||
|
}
|
||||||
|
Set-Content -Path $envPath -Value $envLines -Encoding utf8
|
||||||
|
|
||||||
|
Write-Host "`nZitadel provisioned:" -ForegroundColor Green
|
||||||
|
Write-Host " Project: $ProjectName ($projectId)"
|
||||||
|
Write-Host " API app: $apiClientId"
|
||||||
|
Write-Host " Web app: $webClientId"
|
||||||
|
Write-Host " Issuer: $issuer"
|
||||||
|
if ($webClientSecret) { Write-Host " Web app secret written to .env.prod (WEB_CLIENT_SECRET)" }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Build context: frontend/
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=build /src/build ./build
|
||||||
|
COPY --from=build /src/node_modules ./node_modules
|
||||||
|
COPY --from=build /src/package.json ./package.json
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
CMD ["node", "build/index.js"]
|
||||||
Reference in New Issue
Block a user