From 140d076c94b5224a7cf5837e3bf687b673726837 Mon Sep 17 00:00:00 2001 From: Sourav Chandhok Date: Sat, 22 Aug 2026 21:56:23 -0400 Subject: [PATCH 1/3] build: add production hosting configuration --- .env.example | 9 +- .gitignore | 1 + backend/.dockerignore | 9 ++ backend/Dockerfile | 19 +++ .../src/main/resources/application-prod.yml | 31 ++++ .../config/ProductionConfigurationTest.java | 37 +++++ compose.prod.yml | 42 +++++ deploy/Caddyfile | 11 ++ deploy/production.env.example | 18 +++ deploy/roleimpact.service | 18 +++ docs/production-deployment.md | 152 ++++++++++++++++++ frontend/vercel.json | 6 + tools/validate-production-deployment.ps1 | 109 +++++++++++++ 13 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/src/main/resources/application-prod.yml create mode 100644 backend/src/test/java/com/roleimpact/config/ProductionConfigurationTest.java create mode 100644 compose.prod.yml create mode 100644 deploy/Caddyfile create mode 100644 deploy/production.env.example create mode 100644 deploy/roleimpact.service create mode 100644 docs/production-deployment.md create mode 100644 frontend/vercel.json create mode 100644 tools/validate-production-deployment.ps1 diff --git a/.env.example b/.env.example index 1224ed9..e4b07ea 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,13 @@ DB_URL=jdbc:postgresql://localhost:5432/roleimpact DB_USERNAME=roleimpact DB_PASSWORD=roleimpact CORS_ALLOWED_ORIGIN=http://localhost:5173 +SPRING_PROFILES_ACTIVE=dev +PORT=8080 -# Frontend setting. Leave empty locally to use the Vite /api proxy. +# Production tuning for a small always-on VM. +DB_MAX_POOL_SIZE=5 +DB_MIN_IDLE=0 + +# Leave empty locally to use the Vite /api proxy. On Vercel, set this to the +# public HTTPS backend origin, for example https://api.roleimpact.example. VITE_API_BASE_URL= diff --git a/.gitignore b/.gitignore index b2b8378..6d215c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +deploy/production.env .idea/ .vscode/ *.iml diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..9a43b52 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +.git +.idea +.vscode +target +*.log +.env +.env.* +HELP.md +Dockerfile* diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9f14df6 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21@sha256:613124833fa6718ded9d655a2ebfab6425818c178f899116b93560b6f1c9ffe9 AS build + +WORKDIR /workspace +COPY pom.xml ./ +COPY src ./src +RUN mvn --batch-mode -DskipTests package + +FROM eclipse-temurin:21-jre-jammy@sha256:eebd356ad7358b7094758e5787a6726f332917cfd56feab6457c56dab895cdbf + +WORKDIR /app +COPY --from=build --chown=10001:10001 /workspace/target/roleimpact-api-*.jar app.jar + +USER 10001:10001 +EXPOSE 8080 + +ENV SPRING_PROFILES_ACTIVE=prod \ + JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=20.0 -XX:MaxRAMPercentage=70.0 -XX:+ExitOnOutOfMemoryError" + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..e544025 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,31 @@ +server: + port: ${PORT:8080} + forward-headers-strategy: framework + shutdown: graceful + +spring: + datasource: + hikari: + maximum-pool-size: ${DB_MAX_POOL_SIZE:5} + minimum-idle: ${DB_MIN_IDLE:0} + connection-timeout: 30000 + validation-timeout: 5000 + max-lifetime: 840000 + lifecycle: + timeout-per-shutdown-phase: 20s + +roleimpact: + cors: + allowed-origin: ${CORS_ALLOWED_ORIGIN} + +bootui: + enabled: false + +management: + endpoint: + health: + show-details: never + endpoints: + web: + exposure: + include: health,info diff --git a/backend/src/test/java/com/roleimpact/config/ProductionConfigurationTest.java b/backend/src/test/java/com/roleimpact/config/ProductionConfigurationTest.java new file mode 100644 index 0000000..357438d --- /dev/null +++ b/backend/src/test/java/com/roleimpact/config/ProductionConfigurationTest.java @@ -0,0 +1,37 @@ +package com.roleimpact.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.io.ClassPathResource; + +class ProductionConfigurationTest { + + @Test + void productionProfileUsesPlatformPortAndKeepsDiagnosticsPrivate() throws IOException { + var resource = new ClassPathResource("application-prod.yml"); + assertThat(resource.exists()).isTrue(); + + var propertySource = new YamlPropertySourceLoader() + .load("production", resource) + .getFirst(); + + assertThat(propertySource.getProperty("server.port")).isEqualTo("${PORT:8080}"); + assertThat(propertySource.getProperty("server.forward-headers-strategy")).isEqualTo("framework"); + assertThat(propertySource.getProperty("server.shutdown")).isEqualTo("graceful"); + assertThat(propertySource.getProperty("bootui.enabled")).isEqualTo(false); + assertThat(propertySource.getProperty("management.endpoints.web.exposure.include")) + .isEqualTo("health,info"); + assertThat(propertySource.getProperty("management.endpoint.health.show-details")) + .isEqualTo("never"); + assertThat(propertySource.getProperty("roleimpact.cors.allowed-origin")) + .isEqualTo("${CORS_ALLOWED_ORIGIN}"); + assertThat(propertySource.getProperty("spring.datasource.hikari.maximum-pool-size")) + .isEqualTo("${DB_MAX_POOL_SIZE:5}"); + assertThat(propertySource.getProperty("spring.datasource.hikari.minimum-idle")) + .isEqualTo("${DB_MIN_IDLE:0}"); + } +} diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..e9dc210 --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,42 @@ +name: roleimpact + +services: + api: + build: + context: ./backend + dockerfile: Dockerfile + image: roleimpact-api:production + restart: unless-stopped + env_file: + - ${PRODUCTION_ENV_FILE:-./deploy/production.env} + expose: + - "8080" + networks: + - api_internal + - egress + + caddy: + image: caddy:2.10.2-alpine + restart: unless-stopped + environment: + API_DOMAIN: ${API_DOMAIN:?Set API_DOMAIN in deploy/production.env} + ports: + - "80:80" + - "443:443" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - edge + - api_internal + +networks: + edge: + egress: + api_internal: + internal: true + +volumes: + caddy_data: + caddy_config: diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..5bf6ef4 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,11 @@ +{$API_DOMAIN} { + encode zstd gzip + + reverse_proxy api:8080 { + health_uri /actuator/health + health_interval 10s + health_timeout 3s + fail_duration 30s + max_fails 3 + } +} diff --git a/deploy/production.env.example b/deploy/production.env.example new file mode 100644 index 0000000..186dc0b --- /dev/null +++ b/deploy/production.env.example @@ -0,0 +1,18 @@ +# Copy this file to deploy/production.env on the VM, fill in the placeholders, +# and keep that real file readable only by the deployment user. Never commit it. + +# Public API hostname. Its DNS A/AAAA record must point to the Oracle VM before Caddy starts. +API_DOMAIN=api.example.com + +# Dedicated Neon production database. Keep sslmode=require. +DB_URL=jdbc:postgresql:///?sslmode=require +DB_USERNAME= +DB_PASSWORD= + +# Exact production frontend origin, without a trailing slash. +CORS_ALLOWED_ORIGIN=https://.vercel.app + +SPRING_PROFILES_ACTIVE=prod +PORT=8080 +DB_MAX_POOL_SIZE=5 +DB_MIN_IDLE=0 diff --git a/deploy/roleimpact.service b/deploy/roleimpact.service new file mode 100644 index 0000000..836840a --- /dev/null +++ b/deploy/roleimpact.service @@ -0,0 +1,18 @@ +[Unit] +Description=RoleImpact production services +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/roleimpact +Environment=COMPOSE_PROJECT_NAME=roleimpact +ExecStart=/usr/bin/docker compose --env-file deploy/production.env -f compose.prod.yml up -d --build --remove-orphans +ExecReload=/usr/bin/docker compose --env-file deploy/production.env -f compose.prod.yml up -d --build --remove-orphans +ExecStop=/usr/bin/docker compose --env-file deploy/production.env -f compose.prod.yml down +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/docs/production-deployment.md b/docs/production-deployment.md new file mode 100644 index 0000000..c62bcd0 --- /dev/null +++ b/docs/production-deployment.md @@ -0,0 +1,152 @@ +# Production deployment runbook + +RoleImpact is deployed as a split application: + +- the React/Vite frontend runs on Vercel; +- the Spring Boot API runs in Docker on an Oracle Cloud Ubuntu ARM VM; +- PostgreSQL runs in the dedicated `RoleImpact Production` Neon project; +- Caddy on the VM terminates TLS and is the only public API entry point. + +The production API is intentionally private inside Docker. Only Caddy publishes ports `80` and `443`; it forwards requests to the API over Docker's internal network and checks `/actuator/health` before using the upstream. The API also has a separate Docker egress network solely for outbound TLS connections to Neon; it still exposes no host port. + +## 1. Provision the Oracle VM + +In OCI, create an Always Free-eligible Ubuntu ARM (aarch64) compute instance. A 1 OCPU / 6 GB shape gives the Java API and Docker reasonable headroom. Keep the generated SSH key private and record the public IP address. + +In the OCI security list or network security group, allow only: + +- TCP `22` from your own current public IP address for SSH; +- TCP `80` from `0.0.0.0/0` for the HTTP-to-HTTPS certificate flow; +- TCP `443` from `0.0.0.0/0` for the public API. + +Do **not** add a public rule for TCP `8080`, PostgreSQL, or Docker's internal networks. + +Connect to the server, update Ubuntu, and set the host firewall to match the OCI rules: + +```bash +sudo apt-get update && sudo apt-get upgrade -y +sudo apt-get install -y ufw ca-certificates curl git +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +sudo ufw status numbered +``` + +## 2. Install Docker on Ubuntu ARM + +Use Docker's official Ubuntu repository so Docker Compose v2 is installed with the engine: + +```bash +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +sudo systemctl enable --now docker +docker compose version +``` + +The committed API Dockerfile builds a Linux ARM64-compatible image. Do not install a local PostgreSQL instance on this VM; the API connects to Neon over TLS. + +## 3. Configure DNS before TLS + +Choose an API hostname, for example `api.example.com`. In your DNS provider, add an `A` record for that hostname pointing to the VM's public IPv4 address. If you configure an `AAAA` record, it must also point to a reachable IPv6 address; otherwise omit it. + +Wait for DNS to resolve before starting Caddy: + +```bash +dig +short api.example.com +``` + +Caddy obtains and renews TLS certificates automatically. Starting it before the DNS record is correct causes certificate issuance to fail or be delayed. + +## 4. Place the application and production secrets + +Clone the repository under the systemd service's expected directory: + +```bash +sudo mkdir -p /opt/roleimpact +sudo chown "$USER":"$USER" /opt/roleimpact +git clone /opt/roleimpact +cd /opt/roleimpact +``` + +Copy the safe template, then edit the real server-only file: + +```bash +cp deploy/production.env.example deploy/production.env +chmod 600 deploy/production.env +nano deploy/production.env +``` + +Set `API_DOMAIN`, the exact Vercel production origin in `CORS_ALLOWED_ORIGIN`, and the SSL-enabled Neon JDBC URL, username, and password. Keep `SPRING_PROFILES_ACTIVE=prod`, `DB_MAX_POOL_SIZE=5`, and `DB_MIN_IDLE=0` unless the VM capacity changes. + +`deploy/production.env` is ignored by Git and must never be copied into a commit, issue, log, or chat message. It is the only place where the Neon password belongs. The Compose file maps it into the API container and gives Caddy only the public hostname it needs. + +## 5. Start and persist the services + +Install the boot unit, then start the stack: + +```bash +sudo cp deploy/roleimpact.service /etc/systemd/system/roleimpact.service +sudo systemctl daemon-reload +sudo systemctl enable --now roleimpact +sudo systemctl status roleimpact --no-pager +docker compose --env-file deploy/production.env -f compose.prod.yml ps +``` + +On the first successful API start, Flyway applies the committed database migrations to the empty Neon production database. Confirm that Flyway completed successfully before using the app: + +```bash +docker compose --env-file deploy/production.env -f compose.prod.yml logs api --tail=200 +``` + +## 6. Health and smoke checks + +Verify the public TLS endpoint from your computer: + +```bash +curl --fail --show-error https://api.example.com/actuator/health +``` + +Expect an `UP` response. There must be no response from `http://:8080` because the API is not published outside Docker. + +In Vercel, set `VITE_API_BASE_URL=https://api.example.com` and redeploy the frontend. Open the deployed site, create a small test organization, refresh its saved organization URL, and run one impact test. This verifies browser CORS, persistence, URL recovery, and the impact engine through the real API. + +## 7. Deploy an update + +Use a reviewed commit or release tag, not an untracked local change: + +```bash +cd /opt/roleimpact +git fetch origin --tags +git checkout +sudo systemctl restart roleimpact +docker compose --env-file deploy/production.env -f compose.prod.yml ps +curl --fail --show-error https://api.example.com/actuator/health +``` + +The systemd unit rebuilds the API image, applies any forward Flyway migrations, and restarts both containers. Check `journalctl -u roleimpact -n 100 --no-pager` and the API logs if the health endpoint does not return `UP`. + +## 8. Rollback safely + +If a release fails the health or smoke checks, return to the previous known-good tag or commit and restart: + +```bash +cd /opt/roleimpact +git checkout +sudo systemctl restart roleimpact +curl --fail --show-error https://api.example.com/actuator/health +``` + +Application code and containers roll back immediately. Database migrations are forward-only: do not manually alter Neon tables during an incident. If a migration itself needs reversal, prepare a reviewed follow-up Flyway migration and deploy it normally. + +## Operational checks + +- `sudo ufw status` shows only SSH, `80`, and `443` allowed. +- OCI ingress matches the same rules. +- `docker compose --env-file deploy/production.env -f compose.prod.yml ps` shows both services running. +- `docker volume ls` shows the Caddy volumes; do not remove them because they retain certificates. +- `curl https://api.example.com/actuator/health` remains the single safe API health check. diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..33da267 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [ + { "source": "/:path*", "destination": "/index.html" } + ] +} diff --git a/tools/validate-production-deployment.ps1 b/tools/validate-production-deployment.ps1 new file mode 100644 index 0000000..92a8b28 --- /dev/null +++ b/tools/validate-production-deployment.ps1 @@ -0,0 +1,109 @@ +[CmdletBinding()] +param( + [string]$RepositoryRoot = (Split-Path -Parent $PSScriptRoot) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$failures = [System.Collections.Generic.List[string]]::new() + +function Require-Path { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + $script:failures.Add("Missing required file: $Path") + } +} + +function Require-Text { + param( + [string]$Content, + [string]$Pattern, + [string]$Description + ) + + if ($Content -notmatch $Pattern) { + $script:failures.Add("Missing contract: $Description") + } +} + +$composePath = Join-Path $RepositoryRoot 'compose.prod.yml' +$caddyPath = Join-Path $RepositoryRoot 'deploy/Caddyfile' +$envExamplePath = Join-Path $RepositoryRoot 'deploy/production.env.example' +$servicePath = Join-Path $RepositoryRoot 'deploy/roleimpact.service' +$runbookPath = Join-Path $RepositoryRoot 'docs/production-deployment.md' +$gitignorePath = Join-Path $RepositoryRoot '.gitignore' + +@($composePath, $caddyPath, $envExamplePath, $servicePath, $runbookPath, $gitignorePath) | ForEach-Object { Require-Path $_ } + +if ($failures.Count -eq 0) { + $compose = Get-Content -Raw -LiteralPath $composePath + $caddy = Get-Content -Raw -LiteralPath $caddyPath + $envExample = Get-Content -Raw -LiteralPath $envExamplePath + $service = Get-Content -Raw -LiteralPath $servicePath + $runbook = Get-Content -Raw -LiteralPath $runbookPath + $gitignore = Get-Content -Raw -LiteralPath $gitignorePath + + Require-Text $compose '(?m)^\s+api:\r?$' 'an api service' + Require-Text $compose 'context:\s*\./backend' 'the API Docker build context' + Require-Text $compose '(?m)^\s+caddy:\r?$' 'a Caddy service' + Require-Text $compose 'restart:\s*unless-stopped' 'restart policies' + Require-Text $compose '"80:80"' 'public HTTP exposure' + Require-Text $compose '"443:443"' 'public HTTPS exposure' + if ($compose -match '"?8080:8080"?') { + $failures.Add('The API must not publish port 8080 directly.') + } + Require-Text $compose 'internal:\s*true' 'an internal API network' + Require-Text $compose 'PRODUCTION_ENV_FILE' 'production environment-file mapping' + Require-Text $compose 'caddy_data:' 'persistent Caddy certificate storage' + Require-Text $compose 'caddy_config:' 'persistent Caddy runtime storage' + Require-Text $compose 'API_DOMAIN:\s*\$\{API_DOMAIN' 'Caddy API_DOMAIN environment mapping' + Require-Text $compose '(?m)^\s+- egress\r?$' 'an API outbound network attachment' + Require-Text $compose '(?m)^ egress:\r?$' 'an outbound network declaration' + $caddySection = [regex]::Match($compose, '(?ms)^ caddy:\r?\n(?.*?)(?=^ [a-z_]+:|^networks:)').Groups['content'].Value + if ($caddySection -match '(?m)^\s+env_file:') { + $failures.Add('Caddy must not receive the API database environment file.') + } + + Require-Text $caddy '\{\$API_DOMAIN\}' 'API_DOMAIN Caddy address' + Require-Text $caddy 'reverse_proxy\s+api:8080' 'internal API reverse proxy' + Require-Text $caddy 'health_uri\s+/actuator/health' 'API upstream health checks' + + @('API_DOMAIN=', 'DB_URL=', 'DB_USERNAME=', 'DB_PASSWORD=', 'CORS_ALLOWED_ORIGIN=', 'SPRING_PROFILES_ACTIVE=prod') | ForEach-Object { + Require-Text $envExample ([regex]::Escape($_)) "production environment variable $_" + } + if ($envExample -match '(?m)^DB_PASSWORD=(?!<[^>]+>\r?$).+') { + $failures.Add('The production environment example must not contain a database password.') + } + Require-Text $gitignore '(?m)^deploy/production\.env\r?$' 'Git exclusion for the real production environment file' + + Require-Text $service 'WorkingDirectory=/opt/roleimpact' 'the server deployment directory' + Require-Text $service 'compose\.prod\.yml' 'the production Compose file' + Require-Text $service '--env-file' 'the production environment file' + + @('Ubuntu', 'ARM', 'Docker', 'DNS', 'TLS', 'OCI', 'UFW', 'egress', 'rollback', '/actuator/health') | ForEach-Object { + Require-Text $runbook ("(?i)" + [regex]::Escape($_)) "runbook coverage for $_" + } +} + +if ($failures.Count -gt 0) { + Write-Error ("Production deployment validation failed:`n - " + ($failures -join "`n - ")) + exit 1 +} + +$previousEnvFile = $env:PRODUCTION_ENV_FILE +try { + $env:PRODUCTION_ENV_FILE = './deploy/production.env.example' + Push-Location $RepositoryRoot + & docker compose --env-file deploy/production.env.example -f compose.prod.yml config --quiet + if ($LASTEXITCODE -ne 0) { + throw 'Docker Compose rejected compose.prod.yml.' + } +} +finally { + Pop-Location + $env:PRODUCTION_ENV_FILE = $previousEnvFile +} + +Write-Host 'Production deployment validation passed.' From ec14a4a84af9d111e80cb58ef479f8664bb38e9f Mon Sep 17 00:00:00 2001 From: Sourav Chandhok Date: Sat, 22 Aug 2026 23:08:33 -0400 Subject: [PATCH 2/3] build: add Render backend deployment --- render.yaml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 render.yaml diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..9ea39fb --- /dev/null +++ b/render.yaml @@ -0,0 +1,31 @@ +services: + - type: web + name: roleimpact-api + runtime: docker + plan: free + region: oregon + branch: main + autoDeploy: true + dockerfilePath: ./backend/Dockerfile + dockerContext: ./backend + healthCheckPath: /actuator/health + envVars: + - key: SPRING_PROFILES_ACTIVE + value: prod + - key: DB_URL + sync: false + - key: DB_USERNAME + sync: false + - key: DB_PASSWORD + sync: false + - key: CORS_ALLOWED_ORIGIN + sync: false + - key: DB_MAX_POOL_SIZE + value: "3" + - key: DB_MIN_IDLE + value: "0" + - key: JAVA_TOOL_OPTIONS + value: -XX:InitialRAMPercentage=10.0 -XX:MaxRAMPercentage=60.0 -XX:MaxMetaspaceSize=128m -XX:+ExitOnOutOfMemoryError + +previews: + generation: none From f2feb00c0f4315efbf4f3f8edf744271c7114f93 Mon Sep 17 00:00:00 2001 From: Sourav Chandhok Date: Sat, 22 Aug 2026 23:14:54 -0400 Subject: [PATCH 3/3] build: remove VM deployment artifacts --- .gitignore | 2 +- compose.prod.yml | 42 ------ deploy/Caddyfile | 11 -- deploy/production.env.example | 18 --- deploy/roleimpact.service | 18 --- docs/production-deployment.md | 164 +++++++---------------- tools/validate-production-deployment.ps1 | 99 ++++++-------- 7 files changed, 87 insertions(+), 267 deletions(-) delete mode 100644 compose.prod.yml delete mode 100644 deploy/Caddyfile delete mode 100644 deploy/production.env.example delete mode 100644 deploy/roleimpact.service diff --git a/.gitignore b/.gitignore index 6d215c7..e37995b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .env -deploy/production.env +*.env .idea/ .vscode/ *.iml diff --git a/compose.prod.yml b/compose.prod.yml deleted file mode 100644 index e9dc210..0000000 --- a/compose.prod.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: roleimpact - -services: - api: - build: - context: ./backend - dockerfile: Dockerfile - image: roleimpact-api:production - restart: unless-stopped - env_file: - - ${PRODUCTION_ENV_FILE:-./deploy/production.env} - expose: - - "8080" - networks: - - api_internal - - egress - - caddy: - image: caddy:2.10.2-alpine - restart: unless-stopped - environment: - API_DOMAIN: ${API_DOMAIN:?Set API_DOMAIN in deploy/production.env} - ports: - - "80:80" - - "443:443" - volumes: - - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - - caddy_config:/config - networks: - - edge - - api_internal - -networks: - edge: - egress: - api_internal: - internal: true - -volumes: - caddy_data: - caddy_config: diff --git a/deploy/Caddyfile b/deploy/Caddyfile deleted file mode 100644 index 5bf6ef4..0000000 --- a/deploy/Caddyfile +++ /dev/null @@ -1,11 +0,0 @@ -{$API_DOMAIN} { - encode zstd gzip - - reverse_proxy api:8080 { - health_uri /actuator/health - health_interval 10s - health_timeout 3s - fail_duration 30s - max_fails 3 - } -} diff --git a/deploy/production.env.example b/deploy/production.env.example deleted file mode 100644 index 186dc0b..0000000 --- a/deploy/production.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# Copy this file to deploy/production.env on the VM, fill in the placeholders, -# and keep that real file readable only by the deployment user. Never commit it. - -# Public API hostname. Its DNS A/AAAA record must point to the Oracle VM before Caddy starts. -API_DOMAIN=api.example.com - -# Dedicated Neon production database. Keep sslmode=require. -DB_URL=jdbc:postgresql:///?sslmode=require -DB_USERNAME= -DB_PASSWORD= - -# Exact production frontend origin, without a trailing slash. -CORS_ALLOWED_ORIGIN=https://.vercel.app - -SPRING_PROFILES_ACTIVE=prod -PORT=8080 -DB_MAX_POOL_SIZE=5 -DB_MIN_IDLE=0 diff --git a/deploy/roleimpact.service b/deploy/roleimpact.service deleted file mode 100644 index 836840a..0000000 --- a/deploy/roleimpact.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=RoleImpact production services -Requires=docker.service -After=docker.service network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -RemainAfterExit=yes -WorkingDirectory=/opt/roleimpact -Environment=COMPOSE_PROJECT_NAME=roleimpact -ExecStart=/usr/bin/docker compose --env-file deploy/production.env -f compose.prod.yml up -d --build --remove-orphans -ExecReload=/usr/bin/docker compose --env-file deploy/production.env -f compose.prod.yml up -d --build --remove-orphans -ExecStop=/usr/bin/docker compose --env-file deploy/production.env -f compose.prod.yml down -TimeoutStartSec=0 - -[Install] -WantedBy=multi-user.target diff --git a/docs/production-deployment.md b/docs/production-deployment.md index c62bcd0..e73b019 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -1,152 +1,82 @@ # Production deployment runbook -RoleImpact is deployed as a split application: +RoleImpact uses three managed production services: - the React/Vite frontend runs on Vercel; -- the Spring Boot API runs in Docker on an Oracle Cloud Ubuntu ARM VM; -- PostgreSQL runs in the dedicated `RoleImpact Production` Neon project; -- Caddy on the VM terminates TLS and is the only public API entry point. +- the Spring Boot API runs as a Docker web service on Render; +- PostgreSQL runs in the dedicated `RoleImpact Production` Neon project. -The production API is intentionally private inside Docker. Only Caddy publishes ports `80` and `443`; it forwards requests to the API over Docker's internal network and checks `/actuator/health` before using the upstream. The API also has a separate Docker egress network solely for outbound TLS connections to Neon; it still exposes no host port. +The repository-root `render.yaml` is the source of truth for the API service. Render builds `backend/Dockerfile`, provides HTTPS, monitors `/actuator/health`, and deploys reviewed changes from `main`. -## 1. Provision the Oracle VM +## 1. Merge the deployment configuration -In OCI, create an Always Free-eligible Ubuntu ARM (aarch64) compute instance. A 1 OCPU / 6 GB shape gives the Java API and Docker reasonable headroom. Keep the generated SSH key private and record the public IP address. +Before creating the service, confirm `render.yaml` exists on the repository's `main` branch and required CI checks pass. Do not deploy an unreviewed feature branch as production. -In the OCI security list or network security group, allow only: +## 2. Create the Render service -- TCP `22` from your own current public IP address for SSH; -- TCP `80` from `0.0.0.0/0` for the HTTP-to-HTTPS certificate flow; -- TCP `443` from `0.0.0.0/0` for the public API. +In the Render dashboard, create a Blueprint from the RoleImpact GitHub repository and select **My Workspace**. Review the generated `roleimpact-api` web service before applying it: -Do **not** add a public rule for TCP `8080`, PostgreSQL, or Docker's internal networks. +- runtime: Docker; +- plan: Free; +- region: Oregon, colocated with the Neon US West database; +- branch: `main`; +- health check: `/actuator/health`; +- auto-deploy: enabled. -Connect to the server, update Ubuntu, and set the host firewall to match the OCI rules: +Do not create a Render PostgreSQL database. Production data remains in Neon. -```bash -sudo apt-get update && sudo apt-get upgrade -y -sudo apt-get install -y ufw ca-certificates curl git -sudo ufw allow OpenSSH -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp -sudo ufw enable -sudo ufw status numbered -``` - -## 2. Install Docker on Ubuntu ARM +## 3. Configure secrets -Use Docker's official Ubuntu repository so Docker Compose v2 is installed with the engine: +Enter the following values only in the Render dashboard when the Blueprint requests them: -```bash -sudo install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg -sudo chmod a+r /etc/apt/keyrings/docker.gpg -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null -sudo apt-get update -sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -sudo systemctl enable --now docker -docker compose version -``` +- `DB_URL`: the Neon JDBC URL with `sslmode=require`; +- `DB_USERNAME`: the Neon database role; +- `DB_PASSWORD`: the Neon database password; +- `CORS_ALLOWED_ORIGIN`: the exact Vercel production origin, including `https://` and no trailing slash. -The committed API Dockerfile builds a Linux ARM64-compatible image. Do not install a local PostgreSQL instance on this VM; the API connects to Neon over TLS. +The Blueprint supplies the remaining non-secret settings, including the production Spring profile, conservative database-pool limits, and Java memory limits for the Free instance. Never put production database credentials in Git, documentation, build arguments, issues, or logs. -## 3. Configure DNS before TLS +## 4. Verify the first API deployment -Choose an API hostname, for example `api.example.com`. In your DNS provider, add an `A` record for that hostname pointing to the VM's public IPv4 address. If you configure an `AAAA` record, it must also point to a reachable IPv6 address; otherwise omit it. +Watch the Render deploy until its status is **Live**. On the first successful start, Flyway applies the committed migrations to the Neon production database. -Wait for DNS to resolve before starting Caddy: +Check the public health endpoint: -```bash -dig +short api.example.com +```text +https://.onrender.com/actuator/health ``` -Caddy obtains and renews TLS certificates automatically. Starting it before the DNS record is correct causes certificate issuance to fail or be delayed. +It must return HTTP 200 with status `UP`. Review Render logs for Flyway errors, failed database authentication, out-of-memory exits, or repeated health-check failures before connecting the frontend. -## 4. Place the application and production secrets - -Clone the repository under the systemd service's expected directory: - -```bash -sudo mkdir -p /opt/roleimpact -sudo chown "$USER":"$USER" /opt/roleimpact -git clone /opt/roleimpact -cd /opt/roleimpact -``` +## 5. Connect Vercel -Copy the safe template, then edit the real server-only file: +In the Vercel production environment, set: -```bash -cp deploy/production.env.example deploy/production.env -chmod 600 deploy/production.env -nano deploy/production.env +```text +VITE_API_BASE_URL=https://.onrender.com ``` -Set `API_DOMAIN`, the exact Vercel production origin in `CORS_ALLOWED_ORIGIN`, and the SSL-enabled Neon JDBC URL, username, and password. Keep `SPRING_PROFILES_ACTIVE=prod`, `DB_MAX_POOL_SIZE=5`, and `DB_MIN_IDLE=0` unless the VM capacity changes. +Redeploy the frontend after saving the variable. Confirm the API's `CORS_ALLOWED_ORIGIN` exactly matches the final Vercel production URL. -`deploy/production.env` is ignored by Git and must never be copied into a commit, issue, log, or chat message. It is the only place where the Neon password belongs. The Compose file maps it into the API container and gives Caddy only the public hostname it needs. +## 6. Production smoke test -## 5. Start and persist the services +From the deployed frontend: -Install the boot unit, then start the stack: +1. create a small organization; +2. copy its organization code and reopen it from the landing page; +3. refresh the organization map URL and confirm the same organization loads; +4. create or assign a role and confirm its member connections persist; +5. run an impact test and inspect its recommendation explanation; +6. refresh the impact route and confirm route recovery works. -```bash -sudo cp deploy/roleimpact.service /etc/systemd/system/roleimpact.service -sudo systemctl daemon-reload -sudo systemctl enable --now roleimpact -sudo systemctl status roleimpact --no-pager -docker compose --env-file deploy/production.env -f compose.prod.yml ps -``` - -On the first successful API start, Flyway applies the committed database migrations to the empty Neon production database. Confirm that Flyway completed successfully before using the app: - -```bash -docker compose --env-file deploy/production.env -f compose.prod.yml logs api --tail=200 -``` - -## 6. Health and smoke checks +Also recheck `/actuator/health` and scan recent Render error logs after the smoke test. -Verify the public TLS endpoint from your computer: +## 7. Updates and rollback -```bash -curl --fail --show-error https://api.example.com/actuator/health -``` - -Expect an `UP` response. There must be no response from `http://:8080` because the API is not published outside Docker. - -In Vercel, set `VITE_API_BASE_URL=https://api.example.com` and redeploy the frontend. Open the deployed site, create a small test organization, refresh its saved organization URL, and run one impact test. This verifies browser CORS, persistence, URL recovery, and the impact engine through the real API. - -## 7. Deploy an update - -Use a reviewed commit or release tag, not an untracked local change: - -```bash -cd /opt/roleimpact -git fetch origin --tags -git checkout -sudo systemctl restart roleimpact -docker compose --env-file deploy/production.env -f compose.prod.yml ps -curl --fail --show-error https://api.example.com/actuator/health -``` - -The systemd unit rebuilds the API image, applies any forward Flyway migrations, and restarts both containers. Check `journalctl -u roleimpact -n 100 --no-pager` and the API logs if the health endpoint does not return `UP`. - -## 8. Rollback safely - -If a release fails the health or smoke checks, return to the previous known-good tag or commit and restart: - -```bash -cd /opt/roleimpact -git checkout -sudo systemctl restart roleimpact -curl --fail --show-error https://api.example.com/actuator/health -``` +Merging a reviewed change into `main` triggers an automatic Render deployment. Render keeps the previous deployment serving traffic until the new version becomes healthy. -Application code and containers roll back immediately. Database migrations are forward-only: do not manually alter Neon tables during an incident. If a migration itself needs reversal, prepare a reviewed follow-up Flyway migration and deploy it normally. +If a release fails, roll back to the most recent healthy deployment in the Render dashboard. Database migrations are forward-only: correct a faulty migration with a reviewed follow-up Flyway migration instead of manually changing production tables. -## Operational checks +## Free-plan behavior -- `sudo ufw status` shows only SSH, `80`, and `443` allowed. -- OCI ingress matches the same rules. -- `docker compose --env-file deploy/production.env -f compose.prod.yml ps` shows both services running. -- `docker volume ls` shows the Caddy volumes; do not remove them because they retain certificates. -- `curl https://api.example.com/actuator/health` remains the single safe API health check. +The Render Free service can spin down after inactivity. Its first request after an idle period can take longer while the service starts. Before a portfolio demonstration, open the production site once and wait for the API health check to return `UP`. diff --git a/tools/validate-production-deployment.ps1 b/tools/validate-production-deployment.ps1 index 92a8b28..bb0906c 100644 --- a/tools/validate-production-deployment.ps1 +++ b/tools/validate-production-deployment.ps1 @@ -1,11 +1,15 @@ [CmdletBinding()] param( - [string]$RepositoryRoot = (Split-Path -Parent $PSScriptRoot) + [string]$RepositoryRoot ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = Split-Path -Parent $PSScriptRoot +} + $failures = [System.Collections.Generic.List[string]]::new() function Require-Path { @@ -28,61 +32,50 @@ function Require-Text { } } -$composePath = Join-Path $RepositoryRoot 'compose.prod.yml' -$caddyPath = Join-Path $RepositoryRoot 'deploy/Caddyfile' -$envExamplePath = Join-Path $RepositoryRoot 'deploy/production.env.example' -$servicePath = Join-Path $RepositoryRoot 'deploy/roleimpact.service' +$renderPath = Join-Path $RepositoryRoot 'render.yaml' +$dockerfilePath = Join-Path $RepositoryRoot 'backend/Dockerfile' +$prodConfigPath = Join-Path $RepositoryRoot 'backend/src/main/resources/application-prod.yml' +$vercelPath = Join-Path $RepositoryRoot 'frontend/vercel.json' $runbookPath = Join-Path $RepositoryRoot 'docs/production-deployment.md' -$gitignorePath = Join-Path $RepositoryRoot '.gitignore' -@($composePath, $caddyPath, $envExamplePath, $servicePath, $runbookPath, $gitignorePath) | ForEach-Object { Require-Path $_ } +@($renderPath, $dockerfilePath, $prodConfigPath, $vercelPath, $runbookPath) | ForEach-Object { Require-Path $_ } if ($failures.Count -eq 0) { - $compose = Get-Content -Raw -LiteralPath $composePath - $caddy = Get-Content -Raw -LiteralPath $caddyPath - $envExample = Get-Content -Raw -LiteralPath $envExamplePath - $service = Get-Content -Raw -LiteralPath $servicePath + $render = Get-Content -Raw -LiteralPath $renderPath + $dockerfile = Get-Content -Raw -LiteralPath $dockerfilePath + $prodConfig = Get-Content -Raw -LiteralPath $prodConfigPath + $vercel = Get-Content -Raw -LiteralPath $vercelPath $runbook = Get-Content -Raw -LiteralPath $runbookPath - $gitignore = Get-Content -Raw -LiteralPath $gitignorePath - - Require-Text $compose '(?m)^\s+api:\r?$' 'an api service' - Require-Text $compose 'context:\s*\./backend' 'the API Docker build context' - Require-Text $compose '(?m)^\s+caddy:\r?$' 'a Caddy service' - Require-Text $compose 'restart:\s*unless-stopped' 'restart policies' - Require-Text $compose '"80:80"' 'public HTTP exposure' - Require-Text $compose '"443:443"' 'public HTTPS exposure' - if ($compose -match '"?8080:8080"?') { - $failures.Add('The API must not publish port 8080 directly.') - } - Require-Text $compose 'internal:\s*true' 'an internal API network' - Require-Text $compose 'PRODUCTION_ENV_FILE' 'production environment-file mapping' - Require-Text $compose 'caddy_data:' 'persistent Caddy certificate storage' - Require-Text $compose 'caddy_config:' 'persistent Caddy runtime storage' - Require-Text $compose 'API_DOMAIN:\s*\$\{API_DOMAIN' 'Caddy API_DOMAIN environment mapping' - Require-Text $compose '(?m)^\s+- egress\r?$' 'an API outbound network attachment' - Require-Text $compose '(?m)^ egress:\r?$' 'an outbound network declaration' - $caddySection = [regex]::Match($compose, '(?ms)^ caddy:\r?\n(?.*?)(?=^ [a-z_]+:|^networks:)').Groups['content'].Value - if ($caddySection -match '(?m)^\s+env_file:') { - $failures.Add('Caddy must not receive the API database environment file.') + + Require-Text $render '(?m)^\s*- type: web\r?$' 'a Render web service' + Require-Text $render '(?m)^\s+name: roleimpact-api\r?$' 'the production API service name' + Require-Text $render '(?m)^\s+runtime: docker\r?$' 'the Docker runtime' + Require-Text $render '(?m)^\s+plan: free\r?$' 'the Free service plan' + Require-Text $render '(?m)^\s+region: oregon\r?$' 'the Neon-adjacent Oregon region' + Require-Text $render '(?m)^\s+branch: main\r?$' 'main as the production branch' + Require-Text $render '(?m)^\s+autoDeploy: true\r?$' 'automatic reviewed deployments' + Require-Text $render 'dockerfilePath:\s*\./backend/Dockerfile' 'the backend Dockerfile path' + Require-Text $render 'dockerContext:\s*\./backend' 'the backend Docker build context' + Require-Text $render 'healthCheckPath:\s*/actuator/health' 'the API health check' + + @('DB_URL', 'DB_USERNAME', 'DB_PASSWORD', 'CORS_ALLOWED_ORIGIN') | ForEach-Object { + $secretPattern = '(?ms)- key:\s*' + [regex]::Escape($_) + '\s+sync:\s*false' + Require-Text $render $secretPattern "dashboard-managed secret $_" } - Require-Text $caddy '\{\$API_DOMAIN\}' 'API_DOMAIN Caddy address' - Require-Text $caddy 'reverse_proxy\s+api:8080' 'internal API reverse proxy' - Require-Text $caddy 'health_uri\s+/actuator/health' 'API upstream health checks' + Require-Text $dockerfile '(?m)^FROM .* AS build\r?$' 'a multi-stage container build' + Require-Text $dockerfile '(?m)^USER 10001:10001\r?$' 'a non-root runtime user' + Require-Text $dockerfile 'ENTRYPOINT \["java", "-jar", "/app/app.jar"\]' 'the API container entry point' - @('API_DOMAIN=', 'DB_URL=', 'DB_USERNAME=', 'DB_PASSWORD=', 'CORS_ALLOWED_ORIGIN=', 'SPRING_PROFILES_ACTIVE=prod') | ForEach-Object { - Require-Text $envExample ([regex]::Escape($_)) "production environment variable $_" - } - if ($envExample -match '(?m)^DB_PASSWORD=(?!<[^>]+>\r?$).+') { - $failures.Add('The production environment example must not contain a database password.') - } - Require-Text $gitignore '(?m)^deploy/production\.env\r?$' 'Git exclusion for the real production environment file' + Require-Text $prodConfig 'port:\s*\$\{PORT:8080\}' 'Render PORT binding' + Require-Text $prodConfig 'allowed-origin:\s*\$\{CORS_ALLOWED_ORIGIN\}' 'production CORS isolation' + Require-Text $prodConfig '(?ms)bootui:\s+enabled:\s*false' 'disabled production BootUI' + Require-Text $prodConfig '(?ms)include:\s*health,info' 'minimal actuator exposure' - Require-Text $service 'WorkingDirectory=/opt/roleimpact' 'the server deployment directory' - Require-Text $service 'compose\.prod\.yml' 'the production Compose file' - Require-Text $service '--env-file' 'the production environment file' + Require-Text $vercel '"source":\s*"/:path\*"' 'Vercel SPA route matching' + Require-Text $vercel '"destination":\s*"/index.html"' 'Vercel SPA fallback' - @('Ubuntu', 'ARM', 'Docker', 'DNS', 'TLS', 'OCI', 'UFW', 'egress', 'rollback', '/actuator/health') | ForEach-Object { + @('Render', 'Vercel', 'Neon', 'Flyway', 'DB_URL', 'CORS_ALLOWED_ORIGIN', '/actuator/health', 'rollback') | ForEach-Object { Require-Text $runbook ("(?i)" + [regex]::Escape($_)) "runbook coverage for $_" } } @@ -92,18 +85,4 @@ if ($failures.Count -gt 0) { exit 1 } -$previousEnvFile = $env:PRODUCTION_ENV_FILE -try { - $env:PRODUCTION_ENV_FILE = './deploy/production.env.example' - Push-Location $RepositoryRoot - & docker compose --env-file deploy/production.env.example -f compose.prod.yml config --quiet - if ($LASTEXITCODE -ne 0) { - throw 'Docker Compose rejected compose.prod.yml.' - } -} -finally { - Pop-Location - $env:PRODUCTION_ENV_FILE = $previousEnvFile -} - Write-Host 'Production deployment validation passed.'