feat: implement runtime configuration with API proxy pattern
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 <noreply@anthropic.com>
This commit is contained in:
16
.env.example
16
.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
|
||||
|
||||
236
DOCKER.md
Normal file
236
DOCKER.md
Normal file
@@ -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!
|
||||
10
Dockerfile
10
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
|
||||
|
||||
117
app/api/pastel/[...path]/route.ts
Normal file
117
app/api/pastel/[...path]/route.ts
Normal file
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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/"]
|
||||
|
||||
@@ -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<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<ApiResponse<T>> {
|
||||
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, {
|
||||
|
||||
Reference in New Issue
Block a user