From 2507d98c55b87e3ffa935ddfe785c53f91382fe2 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 20 Jul 2026 07:27:51 +0200 Subject: [PATCH] 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. --- deploy/docker-compose.prod.yml | 273 ++++++++++++++++++++++++++++++ deploy/nginx/conf.d/musify.conf | 120 +++++++++++++ deploy/scripts/migrate.prod.sh | 24 +++ deploy/zitadel/provision.prod.ps1 | 157 +++++++++++++++++ frontend/Dockerfile | 17 ++ 5 files changed, 591 insertions(+) create mode 100644 deploy/docker-compose.prod.yml create mode 100644 deploy/nginx/conf.d/musify.conf create mode 100644 deploy/scripts/migrate.prod.sh create mode 100644 deploy/zitadel/provision.prod.ps1 create mode 100644 frontend/Dockerfile diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..a8e9f91 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -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: diff --git a/deploy/nginx/conf.d/musify.conf b/deploy/nginx/conf.d/musify.conf new file mode 100644 index 0000000..452c4a2 --- /dev/null +++ b/deploy/nginx/conf.d/musify.conf @@ -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; + } +} diff --git a/deploy/scripts/migrate.prod.sh b/deploy/scripts/migrate.prod.sh new file mode 100644 index 0000000..6c41068 --- /dev/null +++ b/deploy/scripts/migrate.prod.sh @@ -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" diff --git a/deploy/zitadel/provision.prod.ps1 b/deploy/zitadel/provision.prod.ps1 new file mode 100644 index 0000000..9053d5b --- /dev/null +++ b/deploy/zitadel/provision.prod.ps1 @@ -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)" } diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..dcf224a --- /dev/null +++ b/frontend/Dockerfile @@ -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"]