From 27ff7f7ffd64e495ab2799560aaae4bcc1b76a00 Mon Sep 17 00:00:00 2001 From: valknarness Date: Fri, 7 Nov 2025 16:05:55 +0100 Subject: [PATCH] feat: implement runtime configuration with API proxy pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace build-time NEXT_PUBLIC_* environment variables with server-side runtime configuration. This allows changing the Pastel API URL without rebuilding the Docker image. **Changes:** - Add Next.js API proxy route at /api/pastel/[...path] for server-side proxying - Update API client to use proxy endpoint instead of direct API URL - Replace NEXT_PUBLIC_API_URL with server-side PASTEL_API_URL - Remove build arguments from Dockerfile (no longer needed) - Simplify docker-compose.yml to use runtime environment variables only - Update all .env files to reflect new configuration approach - Add comprehensive DOCKER.md documentation **Benefits:** - No rebuild required to change API URL - Same image works across all environments (dev/staging/prod) - Better security (API URL not exposed in client bundle) - Simpler deployment and configuration management **Migration:** Old: NEXT_PUBLIC_API_URL (build-time, embedded in bundle) New: PASTEL_API_URL (runtime, read by server proxy) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 16 +- DOCKER.md | 236 ++++++++++++++++++++++++++++++ Dockerfile | 10 +- app/api/pastel/[...path]/route.ts | 117 +++++++++++++++ docker-compose.yml | 7 +- lib/api/client.ts | 8 +- 6 files changed, 372 insertions(+), 22 deletions(-) create mode 100644 DOCKER.md create mode 100644 app/api/pastel/[...path]/route.ts diff --git a/.env.example b/.env.example index f92b861..2a73fb5 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,12 @@ # Pastel API Configuration -# URL of the Pastel API instance -# For Docker Compose: use http://pastel-api:3001 -# For local development: use http://localhost:3001 -NEXT_PUBLIC_API_URL=http://localhost:3001 +# Server-side environment variable (runtime configuration) +# No rebuild needed to change this! -# Application URL (used for sharing and OG images) -NEXT_PUBLIC_APP_URL=http://localhost:3000 +# For Docker Compose: use container name +PASTEL_API_URL=http://pastel-api:3001 + +# For local development: use localhost +# PASTEL_API_URL=http://localhost:3001 + +# For external API: use full URL +# PASTEL_API_URL=https://api.example.com diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..089ea5a --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,236 @@ +# Docker Deployment Guide + +## Runtime Configuration + +This application uses a **Next.js API proxy** pattern to allow runtime configuration without rebuilding the Docker image. + +### How It Works + +1. **Client** makes requests to `/api/pastel/*` +2. **Next.js API Route** (`app/api/pastel/[...path]/route.ts`) proxies requests to the backend +3. **Backend API URL** is read from `PASTEL_API_URL` environment variable at runtime + +This means you can change the backend API URL by simply restarting the container with a different environment variable - **no rebuild required!** + +## Quick Start + +### Using Docker Compose + +```bash +# Start both UI and API +docker-compose up -d + +# The UI will automatically connect to the API container +# (configured via PASTEL_API_URL=http://pastel-api:3001) +``` + +### Using Docker Run + +```bash +# Build the image once +docker build -t pastel-ui . + +# Run with default settings (expects API at http://localhost:3001) +docker run -p 3000:3000 pastel-ui + +# Run with custom API URL (no rebuild needed!) +docker run -p 3000:3000 \ + -e PASTEL_API_URL=http://my-api-server:3001 \ + pastel-ui + +# Run with external API +docker run -p 3000:3000 \ + -e PASTEL_API_URL=https://api.example.com \ + pastel-ui +``` + +## Environment Variables + +### Server-Side (Runtime Configuration) + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `PASTEL_API_URL` | Backend API URL | `http://localhost:3001` | `http://pastel-api:3001` | +| `NODE_ENV` | Node environment | `production` | `production` | +| `PORT` | Server port | `3000` | `3000` | + +**Important:** These can be changed at runtime without rebuilding the image! + +## Configuration Examples + +### Docker Compose with Custom API + +```yaml +# docker-compose.yml +services: + pastel-ui: + image: ghcr.io/valknarness/pastel-ui:latest + ports: + - "3000:3000" + environment: + - PASTEL_API_URL=http://my-custom-api:8080 +``` + +### Docker Compose with External API + +```yaml +services: + pastel-ui: + image: ghcr.io/valknarness/pastel-ui:latest + ports: + - "3000:3000" + environment: + - PASTEL_API_URL=https://api.pastel.example.com +``` + +### Using .env File + +```bash +# .env +PASTEL_API_URL=http://pastel-api:3001 + +# docker-compose.yml will automatically read this +docker-compose up -d +``` + +## Local Development + +```bash +# Install dependencies +pnpm install + +# Create .env.local file +cat > .env.local << EOF +PASTEL_API_URL=http://localhost:3001 +EOF + +# Start development server +pnpm dev + +# Open http://localhost:3000 +``` + +## Building + +```bash +# Build the Docker image +docker build -t pastel-ui . + +# No build arguments needed - configuration is runtime! +``` + +## Health Checks + +The container includes health checks using curl: + +```bash +# Check container health +docker inspect --format='{{.State.Health.Status}}' pastel-ui + +# Manual health check +curl http://localhost:3000/ +``` + +## Troubleshooting + +### API Connection Issues + +**Problem:** Cannot connect to Pastel API + +**Solutions:** + +1. **Check API URL:** + ```bash + docker exec pastel-ui env | grep PASTEL_API_URL + ``` + +2. **Test API connectivity from container:** + ```bash + docker exec pastel-ui curl -f ${PASTEL_API_URL}/api/v1/health + ``` + +3. **Check Docker network:** + ```bash + docker network inspect pastel-network + ``` + +4. **Update API URL without rebuild:** + ```bash + docker-compose down + # Edit .env file + docker-compose up -d + ``` + +### CORS Issues + +If you see CORS errors, the API proxy automatically adds CORS headers. Make sure: +- The `PASTEL_API_URL` is accessible from the container +- The API service is running +- Network connectivity exists between containers + +### Container Logs + +```bash +# View logs +docker-compose logs -f pastel-ui + +# View API proxy logs specifically +docker-compose logs -f pastel-ui | grep -i proxy +``` + +## Architecture + +``` +┌──────────────┐ +│ Browser │ +└──────┬───────┘ + │ fetch('/api/pastel/colors/info') + ▼ +┌──────────────────────────────────┐ +│ Next.js Container (port 3000) │ +│ │ +│ ┌────────────────────────────┐ │ +│ │ API Proxy Route │ │ +│ │ /api/pastel/[...path] │ │ +│ │ │ │ +│ │ Reads: PASTEL_API_URL │ │ +│ │ (runtime env var) │ │ +│ └────────────┬───────────────┘ │ +└───────────────┼───────────────────┘ + │ proxy request + ▼ +┌──────────────────────────────────┐ +│ Pastel API (port 3001) │ +│ /api/v1/colors/info │ +└──────────────────────────────────┘ +``` + +## Benefits of This Approach + +✅ **No Rebuild Required** - Change API URL by restarting container +✅ **Environment Flexibility** - Same image works in dev/staging/prod +✅ **Network Isolation** - Backend API doesn't need public exposure +✅ **CORS Handled** - Proxy adds necessary CORS headers +✅ **Type Safety** - TypeScript client works seamlessly with proxy + +## Migration from Old Approach + +If you were using `NEXT_PUBLIC_API_URL` before: + +**Old (Build-time):** +```dockerfile +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +RUN pnpm build +``` + +**New (Runtime):** +```dockerfile +# No build args needed +RUN pnpm build + +# Runtime configuration via environment +ENV PASTEL_API_URL=http://localhost:3001 +``` + +The new approach requires a rebuild to switch to the proxy pattern, but after that, no more rebuilds are needed for configuration changes! diff --git a/Dockerfile b/Dockerfile index aaa0a75..a61a99f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,15 +29,7 @@ COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production -# Accept build args for environment variables -ARG NEXT_PUBLIC_API_URL -ARG NEXT_PUBLIC_APP_URL - -# Set environment variables for Next.js build -ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} -ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} - -# Build the application +# Build the application (no API URL needed at build time - uses proxy) RUN pnpm build # Stage 3: Runner diff --git a/app/api/pastel/[...path]/route.ts b/app/api/pastel/[...path]/route.ts new file mode 100644 index 0000000..8380868 --- /dev/null +++ b/app/api/pastel/[...path]/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * API Proxy Route for Pastel API + * + * This proxy allows runtime configuration of the Pastel API URL + * without rebuilding the Docker image. The API URL is read from + * the server-side environment variable at runtime. + * + * Client requests go to: /api/pastel/* + * Proxied to: PASTEL_API_URL/* + */ + +const PASTEL_API_URL = process.env.PASTEL_API_URL || 'http://localhost:3001'; + +export async function GET( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params.path, 'GET'); +} + +export async function POST( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params.path, 'POST'); +} + +export async function PUT( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params.path, 'PUT'); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params.path, 'DELETE'); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + return proxyRequest(request, params.path, 'PATCH'); +} + +async function proxyRequest( + request: NextRequest, + pathSegments: string[], + method: string +) { + try { + const path = pathSegments.join('/'); + const targetUrl = `${PASTEL_API_URL}/api/v1/${path}`; + + // Get request body if present + const body = method !== 'GET' && method !== 'DELETE' + ? await request.text() + : undefined; + + // Forward the request to the Pastel API + const response = await fetch(targetUrl, { + method, + headers: { + 'Content-Type': 'application/json', + // Forward relevant headers + ...(request.headers.get('accept') && { + 'Accept': request.headers.get('accept')! + }), + }, + body, + }); + + // Get response data + const data = await response.text(); + + // Return response with same status and headers + return new NextResponse(data, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('content-type') || 'application/json', + // Add CORS headers if needed + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); + } catch (error) { + console.error('Proxy error:', error); + return NextResponse.json( + { + success: false, + error: { + code: 'PROXY_ERROR', + message: error instanceof Error ? error.message : 'Failed to proxy request', + }, + }, + { status: 500 } + ); + } +} + +// Handle OPTIONS requests for CORS +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index a31a466..5328b72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,17 +5,14 @@ services: build: context: . dockerfile: Dockerfile - args: - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001} - NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} image: ghcr.io/valknarness/pastel-ui:latest container_name: pastel-ui ports: - "3000:3000" environment: - NODE_ENV=production - - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001} - - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + # Runtime configuration - can be changed without rebuilding + - PASTEL_API_URL=${PASTEL_API_URL:-http://pastel-api:3001} restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/"] diff --git a/lib/api/client.ts b/lib/api/client.ts index c012101..5b2e2ec 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -35,14 +35,18 @@ export class PastelAPIClient { private baseURL: string; constructor(baseURL?: string) { - this.baseURL = baseURL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + // Use the Next.js API proxy route for runtime configuration + // This allows changing the backend API URL without rebuilding + this.baseURL = baseURL || '/api/pastel'; } private async request( endpoint: string, options?: RequestInit ): Promise> { - const url = `${this.baseURL}/api/v1${endpoint}`; + // Endpoint already includes /api/v1 prefix on backend, + // but our proxy route expects paths after /api/v1/ + const url = `${this.baseURL}${endpoint}`; try { const response = await fetch(url, {