Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.env
*.env
.idea/
.vscode/
*.iml
Expand Down
9 changes: 9 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.git
.idea
.vscode
target
*.log
.env
.env.*
HELP.md
Dockerfile*
19 changes: 19 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
31 changes: 31 additions & 0 deletions backend/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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}");
}
}
82 changes: 82 additions & 0 deletions docs/production-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Production deployment runbook

RoleImpact uses three managed production services:

- the React/Vite frontend runs on Vercel;
- the Spring Boot API runs as a Docker web service on Render;
- PostgreSQL runs in the dedicated `RoleImpact Production` Neon project.

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. Merge the deployment configuration

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.

## 2. Create the Render service

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:

- runtime: Docker;
- plan: Free;
- region: Oregon, colocated with the Neon US West database;
- branch: `main`;
- health check: `/actuator/health`;
- auto-deploy: enabled.

Do not create a Render PostgreSQL database. Production data remains in Neon.

## 3. Configure secrets

Enter the following values only in the Render dashboard when the Blueprint requests them:

- `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 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.

## 4. Verify the first API deployment

Watch the Render deploy until its status is **Live**. On the first successful start, Flyway applies the committed migrations to the Neon production database.

Check the public health endpoint:

```text
https://<render-service>.onrender.com/actuator/health
```

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.

## 5. Connect Vercel

In the Vercel production environment, set:

```text
VITE_API_BASE_URL=https://<render-service>.onrender.com
```

Redeploy the frontend after saving the variable. Confirm the API's `CORS_ALLOWED_ORIGIN` exactly matches the final Vercel production URL.

## 6. Production smoke test

From the deployed frontend:

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.

Also recheck `/actuator/health` and scan recent Render error logs after the smoke test.

## 7. Updates and rollback

Merging a reviewed change into `main` triggers an automatic Render deployment. Render keeps the previous deployment serving traffic until the new version becomes healthy.

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.

## Free-plan behavior

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`.
6 changes: 6 additions & 0 deletions frontend/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"rewrites": [
{ "source": "/:path*", "destination": "/index.html" }
]
}
31 changes: 31 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
@@ -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
88 changes: 88 additions & 0 deletions tools/validate-production-deployment.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
[CmdletBinding()]
param(
[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 {
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")
}
}

$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'

@($renderPath, $dockerfilePath, $prodConfigPath, $vercelPath, $runbookPath) | ForEach-Object { Require-Path $_ }

if ($failures.Count -eq 0) {
$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

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 $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'

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 $vercel '"source":\s*"/:path\*"' 'Vercel SPA route matching'
Require-Text $vercel '"destination":\s*"/index.html"' 'Vercel SPA fallback'

@('Render', 'Vercel', 'Neon', 'Flyway', 'DB_URL', 'CORS_ALLOWED_ORIGIN', '/actuator/health', 'rollback') | 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
}

Write-Host 'Production deployment validation passed.'
Loading