diff --git a/backend/app/api/dependencies/auth.py b/backend/app/api/dependencies/auth.py index db24f68..417e1cc 100644 --- a/backend/app/api/dependencies/auth.py +++ b/backend/app/api/dependencies/auth.py @@ -1,7 +1,8 @@ from typing import Optional -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Header from fastapi.security import OAuth2PasswordBearer +from fastapi.security.utils import get_authorization_scheme_param from sqlalchemy.orm import Session from app.core.auth import get_token_data, TokenExpiredError, TokenInvalidError @@ -109,9 +110,32 @@ def get_current_superuser( return current_user +async def get_optional_token(authorization: str = Header(None)) -> Optional[str]: + """ + Get the token from the Authorization header without requiring it. + + This is a custom dependency that doesn't raise an exception when no token is provided, + unlike the standard OAuth2PasswordBearer. + + Args: + authorization: Authorization header value + + Returns: + The token string if valid Bearer token is provided, None otherwise + """ + if not authorization: + return None + + scheme, token = get_authorization_scheme_param(authorization) + if scheme.lower() != "bearer": + return None + + return token + + def get_optional_current_user( db: Session = Depends(get_db), - token: Optional[str] = Depends(oauth2_scheme) + token: Optional[str] = Depends(get_optional_token) ) -> Optional[User]: """ Get the current user if authenticated, otherwise return None. diff --git a/backend/app/init_db.py b/backend/app/init_db.py new file mode 100644 index 0000000..c0803d1 --- /dev/null +++ b/backend/app/init_db.py @@ -0,0 +1,76 @@ +# app/init_db.py +import logging +from typing import Optional +from sqlalchemy.orm import Session +from app.core.config import settings +from app.crud.user import user as user_crud +from app.schemas.users import UserCreate +from app.core.database import engine + +logger = logging.getLogger(__name__) + + +def init_db(db: Session) -> Optional[UserCreate]: + """ + Initialize database with first superuser if settings are configured and user doesn't exist. + + Returns: + The created or existing superuser, or None if creation fails + """ + # Use default values if not set in environment variables + superuser_email = settings.FIRST_SUPERUSER_EMAIL or "admin@example.com" + superuser_password = settings.FIRST_SUPERUSER_PASSWORD or "admin123" + + if not settings.FIRST_SUPERUSER_EMAIL or not settings.FIRST_SUPERUSER_PASSWORD: + logger.warning( + "First superuser credentials not configured in settings. " + f"Using defaults: {superuser_email}" + ) + + try: + # Check if superuser already exists + existing_user = user_crud.get_by_email(db, email=superuser_email) + + if existing_user: + logger.info(f"Superuser already exists: {existing_user.email}") + return existing_user + + # Create superuser if doesn't exist + user_in = UserCreate( + email=superuser_email, + password=superuser_password, + first_name="Admin", + last_name="User", + is_superuser=True + ) + + user = user_crud.create(db, obj_in=user_in) + logger.info(f"Created first superuser: {user.email}") + + return user + + except Exception as e: + logger.error(f"Error initializing database: {e}") + raise + + +if __name__ == "__main__": + # Configure logging to show info logs + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + with Session(engine) as session: + try: + user = init_db(session) + if user: + print(f"✓ Database initialized successfully") + print(f"✓ Superuser: {user.email}") + else: + print("✗ Failed to initialize database") + except Exception as e: + print(f"✗ Error initializing database: {e}") + raise + finally: + session.close() diff --git a/backend/app/utils/auth_test_utils.py b/backend/app/utils/auth_test_utils.py new file mode 100644 index 0000000..6a5397e --- /dev/null +++ b/backend/app/utils/auth_test_utils.py @@ -0,0 +1,129 @@ +""" +Authentication utilities for testing. +This module provides tools to bypass FastAPI's authentication in tests. +""" +from typing import Callable, Dict, Optional + +from fastapi import FastAPI +from fastapi.security import OAuth2PasswordBearer +from starlette.testclient import TestClient + +from app.api.dependencies.auth import get_current_user, get_optional_current_user +from app.models.user import User + + +def create_test_auth_client( + app: FastAPI, + test_user: User, + extra_overrides: Optional[Dict[Callable, Callable]] = None +) -> TestClient: + """ + Create a test client with authentication pre-configured. + + This bypasses the OAuth2 token validation and directly returns the test user. + + Args: + app: The FastAPI app to test + test_user: The user object to use for authentication + extra_overrides: Additional dependency overrides to apply + + Returns: + TestClient with authentication configured + """ + # First override the oauth2_scheme dependency to return a dummy token + # This prevents FastAPI from trying to extract a real bearer token from the request + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + app.dependency_overrides[oauth2_scheme] = lambda: "dummy_token_for_testing" + + # Then override the get_current_user dependency to return our test user + app.dependency_overrides[get_current_user] = lambda: test_user + + # Apply any extra overrides + if extra_overrides: + for dep, override in extra_overrides.items(): + app.dependency_overrides[dep] = override + + # Create and return the client + return TestClient(app) + + +def create_test_optional_auth_client( + app: FastAPI, + test_user: User +) -> TestClient: + """ + Create a test client with optional authentication pre-configured. + + This is useful for testing endpoints that use get_optional_current_user. + + Args: + app: The FastAPI app to test + test_user: The user object to use for authentication + + Returns: + TestClient with optional authentication configured + """ + # Override the get_optional_current_user dependency + app.dependency_overrides[get_optional_current_user] = lambda: test_user + + # Create and return the client + return TestClient(app) + + +def create_test_superuser_client( + app: FastAPI, + test_user: User +) -> TestClient: + """ + Create a test client with superuser authentication pre-configured. + + Args: + app: The FastAPI app to test + test_user: The user object to use as superuser + + Returns: + TestClient with superuser authentication + """ + # Make sure user is a superuser + test_user.is_superuser = True + + # Use the auth client creation with superuser + return create_test_auth_client(app, test_user) + + +def create_test_unauthenticated_client(app: FastAPI) -> TestClient: + """ + Create a test client that will fail authentication checks. + + This is useful for testing the unauthorized case of protected endpoints. + + Args: + app: The FastAPI app to test + + Returns: + TestClient without authentication + """ + # Any authentication attempts will fail + return TestClient(app) + + +def cleanup_test_client_auth(app: FastAPI) -> None: + """ + Clean up authentication overrides from the FastAPI app. + + Call this after your tests to restore normal authentication behavior. + + Args: + app: The FastAPI app to clean up + """ + # Get all auth dependencies + auth_deps = [ + get_current_user, + get_optional_current_user, + OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + ] + + # Remove overrides + for dep in auth_deps: + if dep in app.dependency_overrides: + del app.dependency_overrides[dep] diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..cc2b4f8 --- /dev/null +++ b/backend/app/utils/security.py @@ -0,0 +1,110 @@ +""" +Security utilities for token-based operations. + +This module provides utilities for creating and verifying signed tokens, +useful for operations like file uploads, password resets, or any other +time-limited, single-use operations. +""" +import base64 +import hashlib +import json +import secrets +import time +from typing import Dict, Any, Optional + +from app.core.config import settings + + +def create_upload_token(file_path: str, content_type: str, expires_in: int = 300) -> str: + """ + Create a signed token for secure file uploads. + + This generates a time-limited, single-use token that can be verified + to ensure the upload is authorized. + + Args: + file_path: The destination path for the file + content_type: The expected content type (e.g., "image/jpeg") + expires_in: Expiration time in seconds (default: 300 = 5 minutes) + + Returns: + A base64 encoded token string + + Example: + >>> token = create_upload_token("/uploads/avatar.jpg", "image/jpeg") + >>> # Send token to client, client includes it in upload request + """ + # Create the payload + payload = { + "path": file_path, + "content_type": content_type, + "exp": int(time.time()) + expires_in, + "nonce": secrets.token_hex(8) # Add randomness to prevent token reuse + } + + # Convert to JSON and encode + payload_bytes = json.dumps(payload).encode('utf-8') + + # Create a signature using the secret key + signature = hashlib.sha256( + payload_bytes + settings.SECRET_KEY.encode('utf-8') + ).hexdigest() + + # Combine payload and signature + token_data = { + "payload": payload, + "signature": signature + } + + # Encode the final token + token_json = json.dumps(token_data) + token = base64.urlsafe_b64encode(token_json.encode('utf-8')).decode('utf-8') + + return token + + +def verify_upload_token(token: str) -> Optional[Dict[str, Any]]: + """ + Verify an upload token and return the payload if valid. + + Args: + token: The token string to verify + + Returns: + The payload dictionary if valid, None if invalid or expired + + Example: + >>> payload = verify_upload_token(token_from_client) + >>> if payload: + ... file_path = payload["path"] + ... content_type = payload["content_type"] + ... # Proceed with upload + ... else: + ... # Token invalid or expired + """ + try: + # Decode the token + token_json = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8') + token_data = json.loads(token_json) + + # Extract payload and signature + payload = token_data["payload"] + signature = token_data["signature"] + + # Verify signature + payload_bytes = json.dumps(payload).encode('utf-8') + expected_signature = hashlib.sha256( + payload_bytes + settings.SECRET_KEY.encode('utf-8') + ).hexdigest() + + if signature != expected_signature: + return None + + # Check expiration + if payload["exp"] < int(time.time()): + return None + + return payload + + except (ValueError, KeyError, json.JSONDecodeError): + return None diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index fbf5b1d..00bcd36 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -4,5 +4,9 @@ echo "Starting Backend" # Apply database migrations alembic upgrade head + +# Initialize database (creates first superuser if needed) +python app/init_db.py + # Execute the command passed to docker run exec "$@" \ No newline at end of file diff --git a/backend/migrate.py b/backend/migrate.py new file mode 100755 index 0000000..15748d6 --- /dev/null +++ b/backend/migrate.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +""" +Database migration helper script. +Provides convenient commands for generating and applying Alembic migrations. +""" +import argparse +import subprocess +import sys +from pathlib import Path + +# Ensure the project root is in the Python path +project_root = Path(__file__).resolve().parent +if str(project_root) not in sys.path: + sys.path.append(str(project_root)) + +try: + # Import settings to check if configuration is working + from app.core.config import settings + + print(f"Using database URL: {settings.database_url}") +except ImportError as e: + print(f"Error importing settings: {e}") + print("Make sure your Python path includes the project root.") + sys.exit(1) + + +def check_models(): + """Check if all models are properly imported""" + print("Checking model imports...") + + try: + # Import all models through the models package + from app.models import __all__ as all_models + print(f"Found {len(all_models)} model(s):") + for model in all_models: + print(f" - {model}") + return True + except Exception as e: + print(f"Error checking models: {e}") + return False + + +def generate_migration(message): + """Generate an Alembic migration with the given message""" + print(f"Generating migration: {message}") + + cmd = ["alembic", "revision", "--autogenerate", "-m", message] + result = subprocess.run(cmd, capture_output=True, text=True) + + print(result.stdout) + if result.returncode != 0: + print("Error generating migration:", file=sys.stderr) + print(result.stderr, file=sys.stderr) + return False + + # Extract revision ID if possible + revision = None + for line in result.stdout.split("\n"): + if "Generating" in line and "..." in line: + try: + # Look for the revision ID, which is typically 12 hex characters + parts = line.split() + for part in parts: + if len(part) >= 12 and all(c in "0123456789abcdef" for c in part[:12]): + revision = part[:12] + break + except Exception: + pass + + if revision: + print(f"Generated revision: {revision}") + else: + print("Generated migration (revision ID not identified)") + + return revision or True + + +def apply_migration(revision=None): + """Apply migrations up to the specified revision or head""" + target = revision or "head" + print(f"Applying migration(s) to: {target}") + + cmd = ["alembic", "upgrade", target] + result = subprocess.run(cmd, capture_output=True, text=True) + + print(result.stdout) + if result.returncode != 0: + print("Error applying migration:", file=sys.stderr) + print(result.stderr, file=sys.stderr) + return False + + print("Migration(s) applied successfully") + return True + + +def show_current(): + """Show current revision""" + print("Current database revision:") + + cmd = ["alembic", "current"] + result = subprocess.run(cmd, capture_output=True, text=True) + + print(result.stdout) + if result.returncode != 0: + print("Error getting current revision:", file=sys.stderr) + print(result.stderr, file=sys.stderr) + return False + + return True + + +def list_migrations(): + """List all migrations and their status""" + print("Listing migrations:") + + cmd = ["alembic", "history", "--verbose"] + result = subprocess.run(cmd, capture_output=True, text=True) + + print(result.stdout) + if result.returncode != 0: + print("Error listing migrations:", file=sys.stderr) + print(result.stderr, file=sys.stderr) + return False + + return True + + +def check_database_connection(): + """Check if database is accessible""" + from sqlalchemy import create_engine + from sqlalchemy.exc import SQLAlchemyError + + try: + engine = create_engine(settings.database_url) + with engine.connect() as conn: + print("✓ Database connection successful!") + return True + except SQLAlchemyError as e: + print(f"✗ Error connecting to database: {e}") + return False + + +def main(): + """Main function""" + parser = argparse.ArgumentParser( + description='Database migration helper for FastNext template' + ) + subparsers = parser.add_subparsers(dest='command', help='Command to run') + + # Generate command + generate_parser = subparsers.add_parser('generate', help='Generate a migration') + generate_parser.add_argument('message', help='Migration message') + + # Apply command + apply_parser = subparsers.add_parser('apply', help='Apply migrations') + apply_parser.add_argument('--revision', help='Specific revision to apply to') + + # List command + subparsers.add_parser('list', help='List migrations') + + # Current command + subparsers.add_parser('current', help='Show current revision') + + # Check command + subparsers.add_parser('check', help='Check database connection and models') + + # Auto command (generate and apply) + auto_parser = subparsers.add_parser('auto', help='Generate and apply migration') + auto_parser.add_argument('message', help='Migration message') + + args = parser.parse_args() + + if args.command == 'generate': + check_models() + generate_migration(args.message) + + elif args.command == 'apply': + apply_migration(args.revision) + + elif args.command == 'list': + list_migrations() + + elif args.command == 'current': + show_current() + + elif args.command == 'check': + check_database_connection() + check_models() + + elif args.command == 'auto': + check_models() + revision = generate_migration(args.message) + if revision: + proceed = input("\nPress Enter to apply migration or Ctrl+C to abort... ") + apply_migration() + + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/backend/tests/api/dependencies/__init__.py b/backend/tests/api/dependencies/__init__.py new file mode 100644 index 0000000..0e61830 --- /dev/null +++ b/backend/tests/api/dependencies/__init__.py @@ -0,0 +1 @@ +# tests/api/dependencies/__init__.py diff --git a/backend/tests/api/dependencies/test_auth_dependencies.py b/backend/tests/api/dependencies/test_auth_dependencies.py new file mode 100644 index 0000000..62755d8 --- /dev/null +++ b/backend/tests/api/dependencies/test_auth_dependencies.py @@ -0,0 +1,213 @@ +# tests/api/dependencies/test_auth_dependencies.py +import pytest +import uuid +from unittest.mock import patch +from fastapi import HTTPException + +from app.api.dependencies.auth import ( + get_current_user, + get_current_active_user, + get_current_superuser, + get_optional_current_user +) +from app.core.auth import TokenExpiredError, TokenInvalidError + + +@pytest.fixture +def mock_token(): + """Fixture providing a mock JWT token""" + return "mock.jwt.token" + + +class TestGetCurrentUser: + """Tests for get_current_user dependency""" + + def test_get_current_user_success(self, db_session, mock_user, mock_token): + """Test successfully getting the current user""" + # Mock get_token_data to return user_id that matches our mock_user + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.return_value.user_id = mock_user.id + + # Call the dependency + user = get_current_user(db=db_session, token=mock_token) + + # Verify the correct user was returned + assert user.id == mock_user.id + assert user.email == mock_user.email + + def test_get_current_user_nonexistent(self, db_session, mock_token): + """Test when the token contains a user ID that doesn't exist""" + # Mock get_token_data to return a non-existent user ID + nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111") + + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.return_value.user_id = nonexistent_id + + # Should raise HTTPException with 404 status + with pytest.raises(HTTPException) as exc_info: + get_current_user(db=db_session, token=mock_token) + + assert exc_info.value.status_code == 404 + assert "User not found" in exc_info.value.detail + + def test_get_current_user_inactive(self, db_session, mock_user, mock_token): + """Test when the user is inactive""" + # Make the user inactive + mock_user.is_active = False + db_session.commit() + + # Mock get_token_data + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.return_value.user_id = mock_user.id + + # Should raise HTTPException with 403 status + with pytest.raises(HTTPException) as exc_info: + get_current_user(db=db_session, token=mock_token) + + assert exc_info.value.status_code == 403 + assert "Inactive user" in exc_info.value.detail + + def test_get_current_user_expired_token(self, db_session, mock_token): + """Test with an expired token""" + # Mock get_token_data to raise TokenExpiredError + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.side_effect = TokenExpiredError("Token expired") + + # Should raise HTTPException with 401 status + with pytest.raises(HTTPException) as exc_info: + get_current_user(db=db_session, token=mock_token) + + assert exc_info.value.status_code == 401 + assert "Token expired" in exc_info.value.detail + + def test_get_current_user_invalid_token(self, db_session, mock_token): + """Test with an invalid token""" + # Mock get_token_data to raise TokenInvalidError + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.side_effect = TokenInvalidError("Invalid token") + + # Should raise HTTPException with 401 status + with pytest.raises(HTTPException) as exc_info: + get_current_user(db=db_session, token=mock_token) + + assert exc_info.value.status_code == 401 + assert "Could not validate credentials" in exc_info.value.detail + + +class TestGetCurrentActiveUser: + """Tests for get_current_active_user dependency""" + + def test_get_current_active_user(self, mock_user): + """Test getting an active user""" + # Ensure user is active + mock_user.is_active = True + + # Call the dependency with mocked current_user + user = get_current_active_user(current_user=mock_user) + + # Should return the same user + assert user == mock_user + + def test_get_current_inactive_user(self, mock_user): + """Test getting an inactive user""" + # Make user inactive + mock_user.is_active = False + + # Should raise HTTPException with 403 status + with pytest.raises(HTTPException) as exc_info: + get_current_active_user(current_user=mock_user) + + assert exc_info.value.status_code == 403 + assert "Inactive user" in exc_info.value.detail + + +class TestGetCurrentSuperuser: + """Tests for get_current_superuser dependency""" + + def test_get_current_superuser(self, mock_user): + """Test getting a superuser""" + # Make user a superuser + mock_user.is_superuser = True + + # Call the dependency with mocked current_user + user = get_current_superuser(current_user=mock_user) + + # Should return the same user + assert user == mock_user + + def test_get_current_non_superuser(self, mock_user): + """Test getting a non-superuser""" + # Ensure user is not a superuser + mock_user.is_superuser = False + + # Should raise HTTPException with 403 status + with pytest.raises(HTTPException) as exc_info: + get_current_superuser(current_user=mock_user) + + assert exc_info.value.status_code == 403 + assert "Not enough permissions" in exc_info.value.detail + + +class TestGetOptionalCurrentUser: + """Tests for get_optional_current_user dependency""" + + def test_get_optional_current_user_with_token(self, db_session, mock_user, mock_token): + """Test getting optional user with a valid token""" + # Mock get_token_data + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.return_value.user_id = mock_user.id + + # Call the dependency + user = get_optional_current_user(db=db_session, token=mock_token) + + # Should return the correct user + assert user is not None + assert user.id == mock_user.id + + def test_get_optional_current_user_no_token(self, db_session): + """Test getting optional user with no token""" + # Call the dependency with no token + user = get_optional_current_user(db=db_session, token=None) + + # Should return None + assert user is None + + def test_get_optional_current_user_invalid_token(self, db_session, mock_token): + """Test getting optional user with an invalid token""" + # Mock get_token_data to raise TokenInvalidError + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.side_effect = TokenInvalidError("Invalid token") + + # Call the dependency + user = get_optional_current_user(db=db_session, token=mock_token) + + # Should return None, not raise an exception + assert user is None + + def test_get_optional_current_user_expired_token(self, db_session, mock_token): + """Test getting optional user with an expired token""" + # Mock get_token_data to raise TokenExpiredError + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.side_effect = TokenExpiredError("Token expired") + + # Call the dependency + user = get_optional_current_user(db=db_session, token=mock_token) + + # Should return None, not raise an exception + assert user is None + + def test_get_optional_current_user_inactive(self, db_session, mock_user, mock_token): + """Test getting optional user when user is inactive""" + # Make the user inactive + mock_user.is_active = False + db_session.commit() + + # Mock get_token_data + with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: + mock_get_data.return_value.user_id = mock_user.id + + # Call the dependency + user = get_optional_current_user(db=db_session, token=mock_token) + + # Should return None for inactive users + assert user is None diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index e69de29..741ae66 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -0,0 +1,98 @@ +# Docker Compose configuration for DEPLOYMENT with pre-built images +# +# IMPORTANT: This configuration is designed for deployment scenarios where you have +# already built and pushed your Docker images to a container registry. +# +# Since this is a template project, you'll need to: +# 1. Build your images: docker-compose build +# 2. Tag them appropriately: docker tag /: +# 3. Push to your registry: docker push /: +# 4. Update the image references below to point to your registry +# +# Example registry paths: +# - Docker Hub: username/project-backend:latest +# - GitHub Container Registry: ghcr.io/username/project-backend:latest +# - GitLab Registry: registry.gitlab.com/username/project/backend:latest +# - Private Registry: registry.example.com/project-backend:latest + +services: + db: + image: postgres:17-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - app-network + restart: unless-stopped + + backend: + # REPLACE THIS with your actual image from your container registry + # Examples: + # image: ghcr.io/your-username/your-project-backend:latest + # image: your-registry.com/your-project/backend:v1.0.0 + # image: username/your-project-backend:latest + image: YOUR_REGISTRY/YOUR_PROJECT_BACKEND:latest + env_file: + - .env + environment: + - DATABASE_URL=${DATABASE_URL} + - SECRET_KEY=${SECRET_KEY} + - ENVIRONMENT=production + - DEBUG=false + - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + depends_on: + db: + condition: service_healthy + networks: + - app-network + restart: unless-stopped + # Uncomment if you need persistent data storage for uploads, etc. + # volumes: + # - ${HOST_DATA_FILES_DIR:-./data}:${DATA_FILES_DIR:-/app/data} + + frontend: + # REPLACE THIS with your actual image from your container registry + # Examples: + # image: ghcr.io/your-username/your-project-frontend:latest + # image: your-registry.com/your-project/frontend:v1.0.0 + # image: username/your-project-frontend:latest + image: YOUR_REGISTRY/YOUR_PROJECT_FRONTEND:latest + environment: + - NODE_ENV=production + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} + depends_on: + - backend + networks: + - app-network + restart: unless-stopped + + # Optional: Add a reverse proxy like nginx or traefik here + # nginx: + # image: nginx:alpine + # ports: + # - "80:80" + # - "443:443" + # volumes: + # - ./nginx.conf:/etc/nginx/nginx.conf:ro + # - ./ssl:/etc/nginx/ssl:ro + # depends_on: + # - frontend + # - backend + # networks: + # - app-network + # restart: unless-stopped + +volumes: + postgres_data: + +networks: + app-network: + driver: bridge