diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92f24a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +node_modules +build +__pycache__ +*.py[cod] +.pytest_cache +.mypy_cache +.coverage +htmlcov +.env +.env.* +!.env.example +logs +*.log +.DS_Store diff --git a/.env.example b/.env.example index d982b51..163b848 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,18 @@ -# Database -DATABASE_URL=postgresql+asyncpg://appuser:password@localhost:5432/gateway_hub +# Database (Compose services use the internal postgres hostname and a sync SQLAlchemy URL) +DATABASE_URL=postgresql://appuser:password123@postgres:5432/gateway_hub DB_NAME=gateway_hub DB_USER=appuser -DB_PASSWORD=your_secure_password_here +DB_PASSWORD=password123 +# DB_PORT is only needed if you add a local override that publishes Postgres. DB_PORT=5432 -# Redis -REDIS_URL=redis://localhost:6379 +# Redis (Compose services use the internal redis hostname) +REDIS_URL=redis://redis:6379 +# REDIS_PORT is only needed if you add a local override that publishes Redis. +REDIS_PORT=6379 # JWT & Security -SECRET_KEY=your-super-secret-key-change-in-production +SECRET_KEY=dev-compose-secret-key-change-me ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7 @@ -17,13 +20,19 @@ REFRESH_TOKEN_EXPIRE_DAYS=7 # Short Links SHORT_CODE_LENGTH=6 SHORT_CODE_ALPHABET=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -SHORT_URL_BASE=https://yourdomain.com/r +SHORT_URL_BASE=http://localhost:8000/r # Application APP_NAME=Gateway Hub DEBUG=false -ENVIRONMENT=production -ALLOWED_ORIGINS=http://localhost:3000,https://app.example.com +ENVIRONMENT=development +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000,http://localhost:8080 + +# Local demo user +LOCAL_DEMO_USER_ENABLED=true +LOCAL_DEMO_USERNAME=user +LOCAL_DEMO_PASSWORD=password123 +LOCAL_DEMO_EMAIL=user@example.com # Rate Limiting RATE_LIMIT_ENABLED=true @@ -47,8 +56,7 @@ REACT_APP_API_URL=http://localhost:8000 REACT_APP_ENABLE_ANALYTICS=true REACT_APP_ENABLE_CUSTOM_DOMAINS=false -# Ports +# Published web ports BACKEND_PORT=8000 FRONTEND_PORT=3000 -DB_PORT=5432 -REDIS_PORT=6379 +NGINX_PORT=8080 diff --git a/Dockerfile b/Dockerfile index bdfedd2..983b4b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,9 @@ FROM python:3.11-slim WORKDIR /app +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + # Install system dependencies RUN apt-get update && apt-get install -y \ postgresql-client \ @@ -22,11 +25,12 @@ COPY scripts/ ./scripts/ RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app USER appuser +EXPOSE 8000 + # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import httpx; httpx.get('http://localhost:8000/health')" || exit 1 -# Run migrations as an explicit deployment step before starting this command: -# /app/scripts/migrate.sh +# Compose runs /app/scripts/migrate.sh in the migrate job before backend startup. # Run application CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..e50dc25 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,26 @@ +# Build the React frontend and serve the static bundle with Nginx. +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json ./ +RUN npm install --no-audit --no-fund + +COPY public/ ./public/ +COPY src/ ./src/ +COPY tsconfig.json ./ + +ARG REACT_APP_API_URL=http://localhost:8000 +ENV REACT_APP_API_URL=${REACT_APP_API_URL} + +RUN npm run build + +FROM nginx:1.27-alpine AS runtime + +COPY --from=build /app/build /usr/share/nginx/html +COPY docker/frontend.conf /etc/nginx/conf.d/default.conf + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost/health >/dev/null || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 299a37d..ebdb6bb 100644 --- a/README.md +++ b/README.md @@ -569,125 +569,83 @@ sudo apt install certbot python3-certbot-nginx sudo certbot certonly --nginx -d api.yourdomain.com ``` -### 8.4 Docker Setup (Optional but Recommended) +### 8.4 Docker Compose Full-Stack Setup -```dockerfile -# Dockerfile -FROM python:3.11-slim +Docker Compose can run the complete local stack: FastAPI backend, React frontend, PostgreSQL, Redis, and an optional Nginx reverse proxy. -WORKDIR /app +**Services included:** -# Install system dependencies -RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/* +- `postgres` — PostgreSQL 15 database used by the backend. +- `redis` — Redis 7 cache/rate-limit store used by the backend. +- `migrate` — one-shot Alembic migration job that runs before the backend starts. +- `backend` — FastAPI API served by Uvicorn on `http://localhost:8000` by default. +- `frontend` — production React static build served by Nginx on `http://localhost:3000` by default. +- `nginx` — optional reverse proxy profile on `http://localhost:8080` that routes `/api/` and `/r/` to the backend and all other requests to the frontend. -# Copy requirements -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +**Prepare environment variables:** -# Copy application -COPY app/ ./app/ -COPY migrations/ ./migrations/ -COPY alembic.ini . -COPY scripts/ ./scripts/ +```bash +cp .env.example .env +``` -# Create non-root user -RUN useradd -m appuser && chown -R appuser:appuser /app -USER appuser +The backend uses synchronous SQLAlchemy/psycopg2, so Compose uses a sync PostgreSQL URL: -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD python -c "import httpx; httpx.get('http://localhost:8000/health')" +```env +DATABASE_URL=postgresql://appuser:password123@postgres:5432/gateway_hub +REDIS_URL=redis://redis:6379 +REACT_APP_API_URL=http://localhost:8000 +``` + +**Start the default local stack:** -# Run migrations as an explicit deployment step before starting this command: -# /app/scripts/migrate.sh -# Run application -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +```bash +docker compose up --build ``` -```yaml -# docker-compose.yml -version: '3.9' +The `migrate` job runs `alembic upgrade head` automatically after PostgreSQL is healthy. The backend waits for that job to complete successfully before it starts, so a normal `docker compose up` creates a coherent application stack without a separate migration command. + +**Open the application:** + +- Frontend: `http://localhost:3000` +- Backend health check: `http://localhost:8000/health` +- API base path: `http://localhost:8000/api/v1` + +**Run with the optional Nginx reverse proxy:** + +```bash +docker compose --profile proxy up --build +``` + +When the `proxy` profile is enabled, open `http://localhost:8080`. Nginx serves the frontend and proxies API calls to the backend from the same origin. +**Database and Redis ports:** + +PostgreSQL and Redis are intentionally not published to the host by default. They are reachable by other Compose services on the internal Docker network as `postgres:5432` and `redis:6379`, which avoids exposing database/cache ports in production-like runs. If you need host access for a local database client, add a temporary `docker-compose.override.yml` such as: + +```yaml services: - db: - image: postgres:15-alpine - container_name: gateway-hub-db - environment: - POSTGRES_DB: gateway_hub - POSTGRES_USER: appuser - POSTGRES_PASSWORD: ${DB_PASSWORD} - volumes: - - postgres_data:/var/lib/postgresql/data + postgres: ports: - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U appuser"] - interval: 10s - timeout: 5s - retries: 5 - redis: - image: redis:7-alpine - container_name: gateway-hub-redis ports: - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - migrate: - build: . - container_name: gateway-hub-migrate - environment: - DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/gateway_hub - depends_on: - db: - condition: service_healthy - command: ["/app/scripts/migrate.sh"] - profiles: - - migrations - - backend: - build: . - container_name: gateway-hub-backend - environment: - DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/gateway_hub - REDIS_URL: redis://redis:6379 - SECRET_KEY: ${SECRET_KEY} - DEBUG: "false" - ports: - - "8000:8000" - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - volumes: - - ./app:/app/app - - ./logs:/app/logs - -volumes: - postgres_data: - -networks: - default: - name: gateway-hub-network ``` -**Run with Docker:** +**Useful commands:** ```bash -# Start dependencies first -docker-compose up -d db redis +# Re-run migrations manually if needed +docker compose run --rm migrate + +# Follow backend logs +docker compose logs -f backend -# Apply schema migrations explicitly -docker-compose run --rm migrate +# Stop and remove containers, keeping named volumes +docker compose down -# Start the API after migrations have completed -docker-compose up -d backend -docker-compose logs -f backend +# Stop and remove containers plus database/cache volumes +docker compose down -v ``` ### 8.5 CI/CD Pipeline (GitHub Actions) diff --git a/docker-compose.yml b/docker-compose.yml index 8624d3d..2f6f758 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: postgres: image: postgres:15-alpine @@ -11,10 +9,8 @@ services: POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" volumes: - postgres_data:/var/lib/postgresql/data - ports: - - "${DB_PORT:-5432}:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-appuser}"] + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-appuser} -d ${DB_NAME:-gateway_hub}"] interval: 10s timeout: 5s retries: 5 @@ -27,8 +23,6 @@ services: command: redis-server --appendonly yes volumes: - redis_data:/data - ports: - - "${REDIS_PORT:-6379}:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -51,8 +45,7 @@ services: postgres: condition: service_healthy command: ["/app/scripts/migrate.sh"] - profiles: - - migrations + restart: "no" networks: - gateway-network @@ -63,25 +56,105 @@ services: container_name: gateway-hub-backend environment: APP_ENV: local - LOCAL_DEMO_USER_ENABLED: "true" - LOCAL_DEMO_USERNAME: "user" - LOCAL_DEMO_PASSWORD: "password123" - LOCAL_DEMO_EMAIL: "user@example.com" - + ENVIRONMENT: ${ENVIRONMENT:-development} + DEBUG: ${DEBUG:-false} + SECRET_KEY: ${SECRET_KEY:-dev-compose-secret-key-change-me} + LOCAL_DEMO_USER_ENABLED: ${LOCAL_DEMO_USER_ENABLED:-true} + LOCAL_DEMO_USERNAME: ${LOCAL_DEMO_USERNAME:-user} + LOCAL_DEMO_PASSWORD: ${LOCAL_DEMO_PASSWORD:-password123} + LOCAL_DEMO_EMAIL: ${LOCAL_DEMO_EMAIL:-user@example.com} DB_USER: ${DB_USER:-appuser} DB_PASSWORD: ${DB_PASSWORD:-password123} DB_NAME: ${DB_NAME:-gateway_hub} - DATABASE_URL: postgresql://${DB_USER:-appuser}:${DB_PASSWORD:-password123}@postgres:5432/${DB_NAME:-gateway_hub} - + REDIS_URL: redis://redis:6379 + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:8000,http://localhost:8080} + SHORT_URL_BASE: ${SHORT_URL_BASE:-http://localhost:8000/r} depends_on: postgres: condition: service_healthy redis: condition: service_healthy + migrate: + condition: service_completed_successfully + ports: + - "${BACKEND_PORT:-8000}:8000" + networks: + - gateway-network + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + args: + REACT_APP_API_URL: ${REACT_APP_API_URL:-http://localhost:8000} + container_name: gateway-hub-frontend + depends_on: + backend: + condition: service_started + ports: + - "${FRONTEND_PORT:-3000}:80" + networks: + - gateway-network + + nginx: + image: nginx:1.27-alpine + container_name: gateway-hub-nginx + profiles: + - proxy + depends_on: + backend: + condition: service_started + frontend: + condition: service_started + command: + - /bin/sh + - -c + - | + cat > /etc/nginx/conf.d/default.conf <<'EOF_NGINX' + upstream gateway_backend { + server backend:8000; + } + + upstream gateway_frontend { + server frontend:80; + } + + server { + listen 80; + server_name _; + + location /api/ { + proxy_pass http://gateway_backend; + proxy_http_version 1.1; + proxy_set_header Host $$host; + proxy_set_header X-Real-IP $$remote_addr; + proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $$scheme; + } + + location /r/ { + proxy_pass http://gateway_backend; + proxy_http_version 1.1; + proxy_set_header Host $$host; + proxy_set_header X-Real-IP $$remote_addr; + proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $$scheme; + } + location / { + proxy_pass http://gateway_frontend; + proxy_http_version 1.1; + proxy_set_header Host $$host; + proxy_set_header X-Real-IP $$remote_addr; + proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $$scheme; + } + } + EOF_NGINX + nginx -g 'daemon off;' ports: - - "8000:8000" + - "${NGINX_PORT:-8080}:80" networks: - gateway-network diff --git a/docker/frontend.conf b/docker/frontend.conf new file mode 100644 index 0000000..3d9e3dd --- /dev/null +++ b/docker/frontend.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp)$ { + expires 7d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } +}