diff --git a/.env.private b/.env.private
new file mode 100644
index 000000000..3295a8a10
--- /dev/null
+++ b/.env.private
@@ -0,0 +1,26 @@
+# =============================================================================
+# PRIVATE API CONFIGURATION (Recommended for production)
+# =============================================================================
+# In this mode, the backend is NOT accessible from the Internet.
+# The frontend uses API Routes as an internal proxy.
+# Users NEVER see /api in URLs, everything is transparent.
+
+# API Mode
+USE_PRIVATE_API=true
+
+# Backend (only accessible internally)
+BACKEND_PORT_MAPPING=
+CORS_ORIGINS=http://bracket-frontend:3000
+INTERNAL_API_URL=http://bracket-backend:8400
+
+# Frontend - User only sees yourdomain.com
+FRONTEND_PORT_MAPPING=172.16.0.4:3000:3000
+# This variable is NOT used in private mode, just for reference
+PUBLIC_API_URL=https://yourdomain.com
+
+# General configuration
+JWT_SECRET=change_me_in_production
+ADMIN_EMAIL=admin@yourdomain.com
+ADMIN_PASSWORD=change_me_in_production
+BASE_URL=https://yourdomain.com
+HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
\ No newline at end of file
diff --git a/.env.public b/.env.public
new file mode 100644
index 000000000..d753d887b
--- /dev/null
+++ b/.env.public
@@ -0,0 +1,24 @@
+# =============================================================================
+# PUBLIC API CONFIGURATION
+# =============================================================================
+# In this mode, both frontend and backend are accessible from the Internet.
+# The browser connects directly to the backend.
+
+# API Mode
+USE_PRIVATE_API=false
+
+# Backend (publicly accessible)
+BACKEND_PORT_MAPPING=172.16.0.4:8400:8400
+CORS_ORIGINS=https://yourdomain.com
+INTERNAL_API_URL=http://bracket-backend:8400
+
+# Frontend
+FRONTEND_PORT_MAPPING=172.16.0.4:3000:3000
+PUBLIC_API_URL=https://api.yourdomain.com
+
+# General configuration
+JWT_SECRET=change_me_in_production
+ADMIN_EMAIL=admin@yourdomain.com
+ADMIN_PASSWORD=change_me_in_production
+BASE_URL=https://api.yourdomain.com
+HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
\ No newline at end of file
diff --git a/API-MODES.md b/API-MODES.md
new file mode 100644
index 000000000..4ebe7fe92
--- /dev/null
+++ b/API-MODES.md
@@ -0,0 +1,225 @@
+# API modes: Private vs Public
+
+Overview
+- The application supports two modes of API access.
+- Private mode (recommended): only the frontend is public; the backend stays private and is reached via the frontend’s internal proxy.
+- Public mode: both frontend and backend are public; the frontend calls the backend directly.
+
+# Private mode (recommended)
+## What it is
+ - The backend is not exposed to the internet.
+ - The frontend exposes an internal proxy under /api that forwards requests to the backend inside the Docker network.
+ - This eliminates CORS issues and reduces the attack surface.
+## How it works
+ - Browser → Frontend (public) → /api/... → Frontend proxy → Backend (private) → Response → Browser.
+## Configure
+ - Frontend
+ - NEXT_PUBLIC_USE_PRIVATE_API = "true" (string)
+ - INTERNAL_API_BASE_URL points to the backend service URL on the Docker network (for example: http://bracket-backend:8400)
+ - Expose only the frontend port publicly (for example: 3000)
+ - Backend
+ - Do not publish the backend port; the backend must be reachable only from the Docker network
+ - CORS_ORIGINS must include the frontend container origin (for example: http://bracket-frontend:3000)
+ - BASE_URL is your public site URL (for example: https://yourdomain.com)
+## When to use
+ - Production behind Cloudflare/Nginx
+ - Single-frontend deployments where the backend should not be directly reachable
+
+## Example Docker-Compose in Private Mode
+```bash
+networks:
+ bracket_lan:
+ driver: bridge
+
+volumes:
+ bracket_pg_data:
+
+services:
+ bracket-backend:
+ container_name: bracket-backend
+ depends_on:
+ - postgres
+ environment:
+ ENVIRONMENT: PRODUCTION
+ # CORS - Internal communication between frontend and backend
+ CORS_ORIGINS: http://bracket-frontend:3000
+ PG_DSN: postgresql://bracket_prod:bracket_prod@postgres:5432/bracket_prod
+ JWT_SECRET: change_me_in_production
+ ADMIN_EMAIL: admin@yourdomain.com
+ ADMIN_PASSWORD: change_me_in_production
+ BASE_URL: https://youdomain.com
+ image: ghcr.io/evroon/bracket-backend
+ networks:
+ - bracket_lan
+ # Backend is PRIVATE - Uncomment ports below for PUBLIC API mode
+ # ports:
+ # - "8400:8400"
+ restart: unless-stopped
+ volumes:
+ - ./backend/static:/app/static
+
+ bracket-frontend:
+ container_name: bracket-frontend
+ environment:
+ # Private API mode - Uses internal proxy to communicate with backend
+ NEXT_PUBLIC_USE_PRIVATE_API: "true"
+ # Backend URL (for proxy reference)
+ NEXT_PUBLIC_API_BASE_URL: http://bracket-backend:8400
+ # Internal URL for proxy (server-side only)
+ INTERNAL_API_BASE_URL: http://bracket-backend:8400
+ NEXT_PUBLIC_HCAPTCHA_SITE_KEY: "10000000-ffff-ffff-ffff-000000000001"
+ # Use local image with internal proxy
+ image: ghcr.io/evroon/bracket-frontend
+ networks:
+ - bracket_lan
+ ports:
+ - "3000:3000"
+ restart: unless-stopped
+
+ postgres:
+ environment:
+ POSTGRES_DB: bracket_prod
+ POSTGRES_PASSWORD: bracket_prod
+ POSTGRES_USER: bracket_prod
+ image: postgres
+ networks:
+ - bracket_lan
+ restart: always
+ volumes:
+ - bracket_pg_data:/var/lib/postgresql
+
+```
+
+
+# Public mode (direct API)
+## What it is
+ - Both frontend and backend are publicly accessible.
+ - The frontend calls the backend directly using the public API URL.
+## Configure
+ - Frontend
+ - NEXT_PUBLIC_USE_PRIVATE_API = "false" (string)
+ - NEXT_PUBLIC_API_BASE_URL is the public API URL (for example: https://api.yourdomain.com)
+ - Backend
+ - Publish the backend port (for example: 8400)
+ - CORS_ORIGINS must include the public frontend domain (for example: https://yourdomain.com)
+ - BASE_URL is the public API base URL (for example: https://api.yourdomain.com)
+## When to use
+ - Multi-client scenarios (mobile + web) or when third parties must call your API directly
+
+## Example Docker-Compose Direct Api
+```bash
+networks:
+ bracket_lan:
+ driver: bridge
+
+volumes:
+ bracket_pg_data:
+
+services:
+ bracket-backend:
+ container_name: bracket-backend
+ depends_on:
+ - postgres
+ environment:
+ # Private API mode - Uses internal proxy to communicate with backend
+ ENVIRONMENT: PRODUCTION
+ # CORS - Internal communication between frontend and backend
+ CORS_ORIGINS: https://youdomain.com
+ PG_DSN: postgresql://bracket_prod:bracket_prod@postgres:5432/bracket_prod
+ JWT_SECRET: change_me_in_production
+ ADMIN_EMAIL: admin@yourdomain.com
+ ADMIN_PASSWORD: change_me_in_production
+ BASE_URL: https://youdomain.com
+ image: ghcr.io/evroon/bracket-backend
+ networks:
+ - bracket_lan
+ # Backend for PUBLIC API mode
+ ports:
+ - "8400:8400"
+ restart: unless-stopped
+ volumes:
+ - ./backend/static:/app/static
+
+ bracket-frontend:
+ container_name: bracket-frontend
+ environment:
+ # Private API mode - Uses internal proxy to communicate with backend
+ NEXT_PUBLIC_USE_PRIVATE_API: "false"
+ # Backend URL (for proxy reference)
+ NEXT_PUBLIC_API_BASE_URL: https://api.youdomain.com
+ NEXT_PUBLIC_HCAPTCHA_SITE_KEY: "10000000-ffff-ffff-ffff-000000000001"
+ # Use local image with internal proxy
+ image: ghcr.io/evroon/bracket-frontend
+ networks:
+ - bracket_lan
+ ports:
+ - "3000:3000"
+ restart: unless-stopped
+
+ postgres:
+ environment:
+ POSTGRES_DB: bracket_prod
+ POSTGRES_PASSWORD: bracket_prod
+ POSTGRES_USER: bracket_prod
+ image: postgres
+ networks:
+ - bracket_lan
+ restart: always
+ volumes:
+ - bracket_pg_data:/var/lib/postgresql
+
+```
+
+# Environment variables
+- Frontend
+ - NEXT_PUBLIC_USE_PRIVATE_API: "true" or "false" to select Private or Public mode
+ - NEXT_PUBLIC_API_BASE_URL: public backend URL used by the browser in Public mode
+ - INTERNAL_API_BASE_URL: internal backend URL used by the proxy in Private mode
+ - NEXT_PUBLIC_HCAPTCHA_SITE_KEY: hCaptcha site key used by forms
+- Backend
+ - ENVIRONMENT: typically PRODUCTION
+ - CORS_ORIGINS: comma-separated list of allowed origins
+ - PG_DSN: PostgreSQL connection string
+ - JWT_SECRET: secret for signing tokens
+ - ADMIN_EMAIL and ADMIN_PASSWORD: initial admin user
+ - BASE_URL: public base URL of the app (Private mode: site URL; Public mode: API URL)
+- Important
+ - Variables prefixed with NEXT_PUBLIC_ are embedded at build time in Next.js. Changing them requires rebuilding the frontend image and restarting the container.
+ - Backend variable changes usually require only a container restart.
+
+# Docker Compose hints
+- Private mode
+ - Publish only the frontend service port.
+ - Do not publish the backend port; both services must share the same private Docker network.
+ - Set NEXT_PUBLIC_USE_PRIVATE_API to "true" and INTERNAL_API_BASE_URL to the backend service URL.
+ - Set backend CORS_ORIGINS to the frontend container origin (for example: http://bracket-frontend:3000).
+- Public mode
+ - Publish both frontend and backend ports.
+ - Set NEXT_PUBLIC_USE_PRIVATE_API to "false" and NEXT_PUBLIC_API_BASE_URL to the public API URL.
+ - Set backend CORS_ORIGINS to include the public frontend domain.
+
+# Reverse proxy notes
+- Private mode
+ - Only proxy the frontend (for example: Cloudflare/Nginx → frontend:3000).
+ - Do not expose the backend externally.
+- Public mode
+ - Proxy both frontend and backend using separate hostnames (for example: yourdomain.com → frontend, api.yourdomain.com → backend).
+
+# Troubleshooting
+- Browser shows requests to bracket-backend:8400 or net::ERR_NAME_NOT_RESOLVED
+ - Cause: the browser is trying to reach the internal Docker hostname directly.
+ - Fix: use Private mode (NEXT_PUBLIC_USE_PRIVATE_API = "true") so the frontend proxy is used, or set NEXT_PUBLIC_API_BASE_URL to a public URL in Public mode.
+- CORS policy errors
+ - Cause: backend CORS_ORIGINS does not match the actual frontend origin.
+ - Fix: in Private mode, allow http://bracket-frontend:3000; in Public mode, allow the public frontend domain.
+- NEXT_PUBLIC_* changes not reflected
+ - Cause: these variables are baked into the Next.js build.
+ - Fix: rebuild the frontend image without cache and restart the container.
+- Verifying Private mode in the browser
+ - In the Network tab, API calls should appear under /api/... on the same origin, not direct calls to the backend hostname.
+
+# Which mode should I use?
+- Use Private mode for most deployments: simpler SSL, no CORS, smaller attack surface.
+- Use Public mode when the API must be directly reachable by other clients or services.
+
+**Default recommendation:** Use **Private Mode** for most deployments.
diff --git a/backend/alembic.ini b/backend/alembic.ini
index 7ba4f8b9d..a2f4308d3 100644
--- a/backend/alembic.ini
+++ b/backend/alembic.ini
@@ -1,7 +1,8 @@
[alembic]
# path to migration scripts
script_location = alembic
-sqlalchemy.url = postgresql://bracket:bracket@localhost/bracket
+# URL placeholder - will be overridden by env.py using environm
+sqlalchemy.url = driver://user:pass@localhost/dbname
# Logging configuration
[loggers]
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
index 02a219e60..f4f8277f8 100644
--- a/backend/alembic/env.py
+++ b/backend/alembic/env.py
@@ -18,7 +18,8 @@
def run_migrations_offline() -> None:
- url = ALEMBIC_CONFIG.get_main_option("sqlalchemy.url")
+ # Use the environment configuration instead of alembic.ini
+ url = str(config.pg_dsn)
context.configure(url=url, target_metadata=Base.metadata, compare_type=True)
with context.begin_transaction():
diff --git a/backend/alembic/versions/274385f2a757_add_on_delete_cascade_to_users_x_clubs.py b/backend/alembic/versions/274385f2a757_add_on_delete_cascade_to_users_x_clubs.py
index 5f447ec75..11d3bd813 100644
--- a/backend/alembic/versions/274385f2a757_add_on_delete_cascade_to_users_x_clubs.py
+++ b/backend/alembic/versions/274385f2a757_add_on_delete_cascade_to_users_x_clubs.py
@@ -17,7 +17,7 @@
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
- op.drop_index("ix_users_email", table_name="users")
+ op.drop_index("ix_users_email", table_name="users", if_exists=True)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.drop_constraint("users_x_clubs_user_id_fkey", "users_x_clubs", type_="foreignkey")
op.drop_constraint("users_x_clubs_club_id_fkey", "users_x_clubs", type_="foreignkey")
@@ -36,6 +36,6 @@ def downgrade() -> None:
op.create_foreign_key(
"users_x_clubs_user_id_fkey", "users_x_clubs", "users", ["user_id"], ["id"]
)
- op.drop_index(op.f("ix_users_email"), table_name="users")
+ op.drop_index(op.f("ix_users_email"), table_name="users", if_exists=True)
op.create_index("ix_users_email", "users", ["email"], unique=False)
# ### end Alembic commands ###
diff --git a/backend/bracket/app.py b/backend/bracket/app.py
index 3180a2451..66c0c6d02 100644
--- a/backend/bracket/app.py
+++ b/backend/bracket/app.py
@@ -40,10 +40,39 @@
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await database.connect()
+
+ # Check database state before any operations
+ table_count = await database.fetch_val(
+ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'"
+ )
+ is_completely_empty = table_count == 0
+
+ # Check if alembic_version table exists (indicates migrations were run before)
+ alembic_version_exists = await database.fetch_val(
+ "SELECT EXISTS (SELECT FROM information_schema.tables "
+ "WHERE table_schema = 'public' AND table_name = 'alembic_version')"
+ )
+
+ logger.info(f"Database state: table_count={table_count}, alembic_version_exists={alembic_version_exists}")
+
+ # Initialize database if needed
await init_db_when_empty()
-
+
+ # Decision logic for migrations:
if config.auto_run_migrations and environment is not Environment.CI:
- alembic_run_migrations()
+ if is_completely_empty:
+ # Fresh database: tables created from schema, no migrations needed
+ logger.info("Fresh database detected, skipping Alembic migrations (tables already created from schema)")
+ elif alembic_version_exists:
+ # Existing database with Alembic history: run migrations to update
+ logger.info("Existing database with migration history detected, running Alembic migrations...")
+ alembic_run_migrations()
+ else:
+ # Existing database without Alembic history: dangerous, log warning
+ logger.warning(
+ "Existing database without Alembic history detected. "
+ "Manual intervention may be required. Skipping migrations to prevent conflicts."
+ )
if environment is Environment.PRODUCTION:
start_cronjobs()
diff --git a/backend/bracket/utils/db_init.py b/backend/bracket/utils/db_init.py
index 0c86fdc9f..af1785fd0 100644
--- a/backend/bracket/utils/db_init.py
+++ b/backend/bracket/utils/db_init.py
@@ -116,20 +116,72 @@ async def create_admin_user() -> UserId:
return user.id
+async def check_alembic_sync() -> bool:
+ """Check if the database is in sync with Alembic migrations."""
+ try:
+ # Check if alembic_version table exists
+ alembic_version_exists = await database.fetch_val(
+ "SELECT EXISTS (SELECT FROM information_schema.tables "
+ "WHERE table_schema = 'public' AND table_name = 'alembic_version')"
+ )
+ return bool(alembic_version_exists)
+ except Exception:
+ return False
+
+
async def init_db_when_empty() -> UserId | None:
table_count = await database.fetch_val(
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'"
)
+
+ # Check if we have a completely empty database (no tables at all)
+ is_completely_empty = table_count == 0
+ # Check if Alembic tracking is in place
+ is_alembic_synced = await check_alembic_sync()
+
if config.admin_email and config.admin_password:
- if (table_count <= 1 and environment != Environment.CI) or (
- environment is Environment.DEVELOPMENT and await get_user(config.admin_email) is None
- ):
- logger.warning("Empty db detected, creating tables...")
- metadata.create_all(engine)
- alembic_stamp_head()
-
- logger.warning("Empty db detected, creating admin user...")
- return await create_admin_user()
+ # Only initialize if database is truly empty OR in development
+ should_initialize = (
+ is_completely_empty or
+ (environment is Environment.DEVELOPMENT and not is_alembic_synced)
+ )
+
+ if should_initialize:
+ if is_completely_empty:
+ logger.warning("Completely empty database detected, initializing from schema...")
+ # Create all tables directly from the schema models
+ metadata.create_all(engine)
+ # Mark the database as up-to-date with Alembic
+ alembic_stamp_head()
+ logger.warning("Database initialized successfully from schema with Alembic sync")
+ elif environment is Environment.DEVELOPMENT:
+ logger.warning("Development environment: ensuring tables exist...")
+ metadata.create_all(engine)
+ if not is_alembic_synced:
+ alembic_stamp_head()
+
+ # Try to create admin user if needed
+ try:
+ existing_user = await get_user(config.admin_email)
+ if existing_user is None:
+ logger.warning("Creating admin user...")
+ return await create_admin_user()
+ else:
+ logger.info("Admin user already exists")
+ except Exception as e:
+ logger.warning(f"Could not check/create admin user: {e}")
+
+ elif table_count > 0:
+ logger.info(f"Existing database detected with {table_count} tables, skipping initialization")
+ # In production with existing database, only try to ensure admin user exists
+ if environment is Environment.PRODUCTION:
+ try:
+ existing_user = await get_user(config.admin_email)
+ if existing_user is None:
+ logger.warning("Admin user missing in existing database, creating...")
+ return await create_admin_user()
+ except Exception as e:
+ logger.warning(f"Could not check admin user in existing database: {e}")
return None
diff --git a/docker-compose.yml b/docker-compose.yml
index ac7468baf..34199e537 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,14 +11,20 @@ services:
depends_on:
- postgres
environment:
- ENVIRONMENT: DEVELOPMENT
- CORS_ORIGINS: http://localhost:3000
- PG_DSN: postgresql://bracket_dev:bracket_dev@postgres:5432/bracket_dev
+ ENVIRONMENT: PRODUCTION
+ # CORS - Internal communication between frontend and backend
+ CORS_ORIGINS: http://bracket-frontend:3000
+ PG_DSN: postgresql://bracket_prod:bracket_prod@postgres:5432/bracket_prod
+ JWT_SECRET: change_me_in_production
+ ADMIN_EMAIL: admin@yourdomain.com
+ ADMIN_PASSWORD: change_me_in_production
+ BASE_URL: http://192.168.100.10:3000
image: ghcr.io/evroon/bracket-backend
networks:
- bracket_lan
- ports:
- - 8400:8400
+ # Backend is PRIVATE - Uncomment ports below for PUBLIC API mode
+ # ports:
+ # - "8400:8400"
restart: unless-stopped
volumes:
- ./backend/static:/app/static
@@ -26,19 +32,27 @@ services:
bracket-frontend:
container_name: bracket-frontend
environment:
- NEXT_PUBLIC_API_BASE_URL: http://localhost:8400
- NEXT_PUBLIC_HCAPTCHA_SITE_KEY: 10000000-ffff-ffff-ffff-000000000001
- image: ghcr.io/evroon/bracket-frontend
+ # Private API mode - Uses internal proxy to communicate with backend
+ NEXT_PUBLIC_USE_PRIVATE_API: "true"
+ # Backend URL (for proxy reference)
+ NEXT_PUBLIC_API_BASE_URL: http://bracket-backend:8400
+ # Internal URL for proxy (server-side only)
+ INTERNAL_API_BASE_URL: http://bracket-backend:8400
+ NEXT_PUBLIC_HCAPTCHA_SITE_KEY: "10000000-ffff-ffff-ffff-000000000001"
+ # Use local image with internal proxy
+ image: bracket-frontend-local
+ networks:
+ - bracket_lan
ports:
- - 3000:3000
+ - "3000:3000"
restart: unless-stopped
postgres:
environment:
- POSTGRES_DB: bracket_dev
- POSTGRES_PASSWORD: bracket_dev
- POSTGRES_USER: bracket_dev
- image: postgres
+ POSTGRES_DB: bracket_prod
+ POSTGRES_PASSWORD: bracket_prod
+ POSTGRES_USER: bracket_prod
+ image: postgres:15
networks:
- bracket_lan
restart: always
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 3dda8be45..caa81fbfc 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -17,7 +17,8 @@ WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
-RUN NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER \
+RUN NEXT_PUBLIC_USE_PRIVATE_API=true \
+ NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER \
NEXT_PUBLIC_HCAPTCHA_SITE_KEY=NEXT_PUBLIC_HCAPTCHA_SITE_KEY_PLACEHOLDER \
yarn build
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
index 52e831b43..254b73c16 100644
--- a/frontend/next-env.d.ts
+++ b/frontend/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
diff --git a/frontend/src/pages/api/[...path].ts b/frontend/src/pages/api/[...path].ts
new file mode 100644
index 000000000..a472ad570
--- /dev/null
+++ b/frontend/src/pages/api/[...path].ts
@@ -0,0 +1,73 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+import axios from 'axios';
+
+const INTERNAL_API_URL = process.env.INTERNAL_API_BASE_URL || 'http://bracket-backend:8400';
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const { path, ...query } = req.query;
+ const apiPath = Array.isArray(path) ? path.join('/') : path || '';
+
+ // Build internal backend URL
+ const url = `${INTERNAL_API_URL}/${apiPath}`;
+
+ // Build query string only for valid parameters
+ const validQuery: Record = {};
+ Object.entries(query).forEach(([key, value]) => {
+ if (typeof value === 'string') {
+ validQuery[key] = value;
+ }
+ });
+
+ const queryString = new URLSearchParams(validQuery).toString();
+ const fullUrl = queryString ? `${url}?${queryString}` : url;
+
+ // Configure headers, copying relevant ones from original request
+ const headers: Record = {};
+
+ // Copy Content-Type if exists
+ if (req.headers['content-type']) {
+ headers['Content-Type'] = req.headers['content-type'];
+ }
+
+ // Pass Authorization header if exists
+ if (req.headers.authorization) {
+ headers.Authorization = req.headers.authorization;
+ }
+
+ // Pass other relevant headers
+ if (req.headers.accept) {
+ headers.Accept = req.headers.accept;
+ }
+
+ console.log(`[API Proxy] ${req.method} ${fullUrl}`);
+
+ // Make request to internal backend
+ const response = await axios({
+ method: req.method as any,
+ url: fullUrl,
+ data: req.method !== 'GET' && req.method !== 'HEAD' ? req.body : undefined,
+ headers,
+ timeout: 30000,
+ validateStatus: () => true, // Don't throw error on 4xx/5xx codes
+ });
+
+ // Copy important response headers
+ if (response.headers['content-type']) {
+ res.setHeader('Content-Type', response.headers['content-type']);
+ }
+
+ // Return response
+ res.status(response.status).json(response.data);
+ } catch (error: any) {
+ console.error('[API Proxy] Error:', error.message);
+
+ if (error.code === 'ECONNREFUSED') {
+ res.status(503).json({ error: 'Backend service unavailable' });
+ } else if (error.code === 'ETIMEDOUT') {
+ res.status(504).json({ error: 'Backend timeout' });
+ } else {
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/services/adapter.tsx b/frontend/src/services/adapter.tsx
index 254738380..964250fb5 100644
--- a/frontend/src/services/adapter.tsx
+++ b/frontend/src/services/adapter.tsx
@@ -53,9 +53,21 @@ export function requestSucceeded(result: AxiosResponse | AxiosError) {
}
export function getBaseApiUrl() {
- return process.env.NEXT_PUBLIC_API_BASE_URL != null
- ? process.env.NEXT_PUBLIC_API_BASE_URL
- : 'http://localhost:8400';
+ // Check if internal proxy should be used (private API)
+ const usePrivateApi = process.env.NEXT_PUBLIC_USE_PRIVATE_API === 'true';
+
+ if (usePrivateApi) {
+ // Private mode: use frontend API routes as proxy
+ // Users never see /api in URLs, only used internally
+ return typeof window !== 'undefined' && window.location.origin
+ ? `${window.location.origin}/api`
+ : '/api';
+ } else {
+ // Public mode: connect directly to backend
+ return process.env.NEXT_PUBLIC_API_BASE_URL != null
+ ? process.env.NEXT_PUBLIC_API_BASE_URL
+ : 'http://localhost:8400';
+ }
}
export function createAxios() {
diff --git a/switch-api-mode.sh b/switch-api-mode.sh
new file mode 100755
index 000000000..a9c8425ad
--- /dev/null
+++ b/switch-api-mode.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+
+# Script to switch between public and private API modes
+# Usage: ./switch-api-mode.sh [private|public]
+
+set -e
+
+MODE=${1:-private}
+
+case $MODE in
+ "private")
+ echo "🔒 Configuring PRIVATE API (secure mode)..."
+ cp .env.private .env
+ echo "✅ Backend will NOT be accessible from Internet"
+ echo "✅ Frontend will use TRANSPARENT internal proxy"
+ echo "✅ Users will NEVER see /api in URLs"
+ echo "✅ Only one domain needed: yourdomain.com"
+ echo "✅ Enhanced security"
+ ;;
+ "public")
+ echo "🌐 Configuring PUBLIC API..."
+ cp .env.public .env
+ echo "⚠️ Backend WILL be accessible from Internet"
+ echo "⚠️ Users will see requests to api.yourdomain.com"
+ echo "⚠️ Browser connects directly to backend"
+ echo "⚠️ Requires correct CORS configuration"
+ echo "⚠️ Two domains required"
+ ;;
+ *)
+ echo "❌ Invalid mode. Use: private or public"
+ echo "Example: ./switch-api-mode.sh private"
+ exit 1
+ ;;
+esac
+
+echo ""
+echo "📝 .env file configured for mode: $MODE"
+echo "🐳 Run: docker-compose down && docker-compose up -d"
+echo ""
+
+if [ "$MODE" = "private" ]; then
+ echo "✅ NGINX CONFIGURATION FOR PRIVATE MODE:"
+ echo " - Only configure: yourdomain.com → 172.16.0.4:3000"
+ echo " - DO NOT configure api.yourdomain.com"
+ echo " - Users only see normal frontend URLs"
+elif [ "$MODE" = "public" ]; then
+ echo "⚠️ IMPORTANT for public mode:"
+ echo " - Configure nginx for yourdomain.com → 172.16.0.4:3000"
+ echo " - Configure nginx for api.yourdomain.com → 172.16.0.4:8400"
+ echo " - Make sure CORS_ORIGINS is correct"
+fi
\ No newline at end of file