diff --git a/.env.example b/.env.example index 0b0ad24..f471a22 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,14 @@ # ── Shared ── NODE_ENV=development +# ── Database (PostgreSQL) ── +DATABASE_URL=postgresql://blog:postgres@localhost:5432/blog_dev +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=blog_dev +DATABASE_USER=blog +DATABASE_PASSWORD=postgres + # ── API (NestJS) ── API_PORT=3001 diff --git a/.gitignore b/.gitignore index 6aabb46..cd97c01 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,8 @@ web_modules/ .turbo +.pnpm-store* + # Yarn Integrity file .yarn-integrity @@ -85,6 +87,10 @@ web_modules/ .env.production.local .env.local +# TLS/SSL certificates (never commit private keys) +infra/ssl/* +!infra/ssl/.gitkeep + # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9bf6e18 --- /dev/null +++ b/Makefile @@ -0,0 +1,122 @@ +SHELL := /bin/bash + +ROOT_DIR := $(shell pwd) +COMPOSE_DEV := docker compose -f infra/docker-compose.yml +COMPOSE_PROD := docker compose -f infra/docker-compose.prod.yml + +DOMAIN ?= +EMAIL ?= + +.PHONY: help \ + dev-up dev-down dev-logs dev-ps dev-restart \ + prod-build prod-up prod-down prod-logs prod-ps prod-restart \ + deploy deploy-rebuild health \ + ssl-selfsigned ssl-certbot ssl-install ssl-renew-test + +help: + @echo "Available targets:" + @echo " make dev-up # Start development stack" + @echo " make dev-down # Stop development stack" + @echo " make dev-logs # Follow development logs" + @echo " make dev-ps # Show development service status" + @echo " make dev-restart # Restart development stack" + @echo "" + @echo " make prod-build # Build production images" + @echo " make prod-up # Start production stack" + @echo " make prod-down # Stop production stack" + @echo " make prod-logs # Follow production logs" + @echo " make prod-ps # Show production service status" + @echo " make prod-restart # Restart production stack" + @echo "" + @echo " make deploy # Build + start production stack" + @echo " make deploy-rebuild # Down + build(no-cache) + up" + @echo " make health # Check HTTP/HTTPS + compose status" + @echo "" + @echo " make ssl-selfsigned # Generate local self-signed certs in infra/ssl" + @echo " make ssl-certbot DOMAIN=example.com EMAIL=ops@example.com" + @echo " # Request Let's Encrypt cert (standalone)" + @echo " make ssl-install DOMAIN=example.com" + @echo " # Copy LE certs into infra/ssl and restart nginx" + @echo " make ssl-renew-test # Dry-run certbot renew" + +dev-up: + $(COMPOSE_DEV) up + +dev-down: + $(COMPOSE_DEV) down + +dev-logs: + $(COMPOSE_DEV) logs -f + +dev-ps: + $(COMPOSE_DEV) ps + +dev-restart: + $(COMPOSE_DEV) down + $(COMPOSE_DEV) up -d + +prod-build: + $(COMPOSE_PROD) build + +prod-up: + $(COMPOSE_PROD) up -d + +prod-down: + $(COMPOSE_PROD) down + +prod-logs: + $(COMPOSE_PROD) logs -f + +prod-ps: + $(COMPOSE_PROD) ps + +prod-restart: + $(COMPOSE_PROD) down + $(COMPOSE_PROD) up -d + +deploy: + $(COMPOSE_PROD) build + $(COMPOSE_PROD) up -d + +deploy-rebuild: + $(COMPOSE_PROD) down + $(COMPOSE_PROD) build --no-cache + $(COMPOSE_PROD) up -d + +health: + @echo "== Compose status ==" + $(COMPOSE_PROD) ps + @echo "" + @echo "== HTTP check (expect 301) ==" + @curl -sS -I http://localhost:80 | head -1 + @echo "== HTTPS check (expect 200) ==" + @curl -k -sS -o /dev/null -w "%{http_code}\n" https://localhost:443 + +ssl-selfsigned: + mkdir -p infra/ssl + docker run --rm -v $(ROOT_DIR)/infra/ssl:/out alpine:3.21 sh -lc "apk add --no-cache openssl >/dev/null && openssl req -x509 -nodes -newkey rsa:2048 -keyout /out/key.pem -out /out/cert.pem -days 365 -subj '/CN=localhost'" + chmod 600 infra/ssl/key.pem + @echo "Self-signed certs created at infra/ssl/cert.pem and infra/ssl/key.pem" + +ssl-certbot: + @if [ -z "$(DOMAIN)" ] || [ -z "$(EMAIL)" ]; then \ + echo "Usage: make ssl-certbot DOMAIN=example.com EMAIL=ops@example.com"; \ + exit 1; \ + fi + sudo certbot certonly --standalone -d $(DOMAIN) --email $(EMAIL) --agree-tos --no-eff-email + +ssl-install: + @if [ -z "$(DOMAIN)" ]; then \ + echo "Usage: make ssl-install DOMAIN=example.com"; \ + exit 1; \ + fi + mkdir -p infra/ssl + sudo cp /etc/letsencrypt/live/$(DOMAIN)/fullchain.pem infra/ssl/cert.pem + sudo cp /etc/letsencrypt/live/$(DOMAIN)/privkey.pem infra/ssl/key.pem + sudo chown $(USER):$(USER) infra/ssl/cert.pem infra/ssl/key.pem + chmod 600 infra/ssl/key.pem + $(COMPOSE_PROD) restart nginx + @echo "Installed Let's Encrypt certs for $(DOMAIN) and restarted nginx" + +ssl-renew-test: + sudo certbot renew --dry-run diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 4b56acf..a4839a6 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -3,6 +3,8 @@ /node_modules /build +generated + # Logs logs *.log diff --git a/apps/frontend/jest.config.js b/apps/frontend/jest.config.js new file mode 100644 index 0000000..fa1c9d7 --- /dev/null +++ b/apps/frontend/jest.config.js @@ -0,0 +1,22 @@ +import nextJest from 'next/jest' + +const createJestConfig = nextJest({ + dir: './', +}) + +const config = { + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/jest.setup.ts'], + testMatch: ['**/*.spec.ts?(x)'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.spec.{ts,tsx}', + '!src/**/layout.tsx', + ], + coverageDirectory: 'coverage', +} + +export default createJestConfig(config) diff --git a/apps/frontend/jest.config.ts b/apps/frontend/jest.config.ts index b6e4e5a..c74570e 100644 --- a/apps/frontend/jest.config.ts +++ b/apps/frontend/jest.config.ts @@ -8,7 +8,7 @@ const createJestConfig = nextJest({ const config: Config = { testEnvironment: 'jsdom', setupFilesAfterEnv: ['/jest.setup.ts'], - testRegex: '.*\\.spec\\.tsx?$', + testMatch: ['**/*.spec.ts?(x)'], moduleNameMapper: { '^@/(.*)$': '/src/$1', }, diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 0c34181..8bb35f8 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -25,5 +25,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "jest.config.ts", "jest.setup.ts"] } diff --git a/infra/Dockerfile b/infra/Dockerfile index 8c603db..e0c5bf1 100644 --- a/infra/Dockerfile +++ b/infra/Dockerfile @@ -20,7 +20,7 @@ COPY apps/api ./apps/api COPY tsconfig.json ./ RUN pnpm install --frozen-lockfile -RUN pnpm --filter @dpep/api run build +RUN pnpm --filter api run build # Stage 3: Backend Runtime FROM node:24-alpine AS backend-prod @@ -37,7 +37,7 @@ COPY --from=backend-build /app/apps/api/package.json ./apps/api/ EXPOSE 3000 ENV NODE_ENV=production -CMD ["node", "apps/api/dist/main.js"] +CMD ["node", "apps/api/dist/src/main.js"] # Stage 4: Build Frontend FROM node:24-alpine AS frontend-build diff --git a/infra/README.md b/infra/README.md index 6745612..60a994f 100644 --- a/infra/README.md +++ b/infra/README.md @@ -14,28 +14,47 @@ Multi-stage Docker build configuration for backend (NestJS) and frontend (Next.j docker build -f infra/Dockerfile -t blog-nest:latest . ``` -### `docker-compose.prod.yml` -Production Docker Compose orchestration with PostgreSQL, Redis, Nginx, and both services. - -**Usage:** -```bash -cd deploy -docker compose -f docker-compose.prod.yml up -d -``` - -**Or from root:** -```bash -docker compose -f deploy/docker-compose.prod.yml up -d -``` - ### `docker-compose.yml` -Development Docker Compose setup with hot-reload volumes and exposed ports. +**Base configuration** with shared service definitions. Contains common settings for both development and production environments. -**Usage:** +### `docker-compose.override.yml` +**Development overrides** - automatically loaded by Docker Compose CLI when running `docker-compose up`. Includes: +- Development container names (with `_dev` suffix) +- Hot-reload code volumes +- Published ports for local access +- Debug logging level + +### `docker-compose.prod.yml` +**Production overrides** - applied only when explicitly specified. Includes: +- Production container names +- Health checks with longer intervals +- Restricted port access (localhost only) +- Logging volumes +- SSL/TLS configuration +- Default fallback values for credentials + +## Usage + +### Development ```bash -cd deploy -docker compose up -d +cd infra +docker compose up ``` +Or from root: +```bash +docker compose -f infra/docker-compose.yml -f infra/docker-compose.override.yml up +``` + +### Production +```bash +docker compose -f infra/docker-compose.yml -f infra/docker-compose.prod.yml up -d +``` + +### Structure +The three-file approach eliminates duplication: +- `docker-compose.yml` = base + shared config +- `docker-compose.override.yml` = dev-specific overrides (auto-loaded) +- `docker-compose.prod.yml` = prod-specific overrides (explicit) ### `nginx.conf` Nginx reverse proxy configuration with: diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index e4544c4..dc4d9b7 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -1,4 +1,5 @@ -version: '3.8' +# Production Docker Compose - for production deployment +# Usage: pnpm docker:prod services: # PostgreSQL Database @@ -6,16 +7,17 @@ services: image: postgres:17-alpine container_name: blog_postgres restart: unless-stopped + env_file: ../.env environment: - POSTGRES_USER: ${DB_USER:-blog} - POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme} - POSTGRES_DB: ${DB_NAME:-blog_prod} + POSTGRES_USER: ${DATABASE_USER:-blog} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-changeme} + POSTGRES_DB: ${DATABASE_NAME:-blog_prod} volumes: - postgres_data:/var/lib/postgresql/data ports: - "127.0.0.1:5432:5432" # Only expose to localhost healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-blog}"] + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-blog}"] interval: 10s timeout: 5s retries: 5 @@ -27,6 +29,7 @@ services: image: redis:7-alpine container_name: blog_redis restart: unless-stopped + env_file: ../.env command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-changeme} volumes: - redis_data:/data @@ -44,10 +47,11 @@ services: api: build: context: .. - dockerfile: deploy/Dockerfile + dockerfile: infra/Dockerfile target: backend-prod container_name: blog_api restart: unless-stopped + env_file: ../.env depends_on: postgres: condition: service_healthy @@ -56,7 +60,7 @@ services: environment: NODE_ENV: production PORT: 3000 - DATABASE_URL: postgresql://${DB_USER:-blog}:${DB_PASSWORD:-changeme}@postgres:5432/${DB_NAME:-blog_prod} + DATABASE_URL: postgresql://${DATABASE_USER:-blog}:${DATABASE_PASSWORD:-changeme}@postgres:${DATABASE_PORT:-5432}/${DATABASE_NAME:-blog_prod} REDIS_URL: redis://:${REDIS_PASSWORD:-changeme}@redis:6379 API_PORT: 3000 LOG_LEVEL: ${LOG_LEVEL:-info} @@ -71,18 +75,20 @@ services: networks: - blog_network volumes: - - /var/log/blog:/app/logs # Optional: mount logs to host + - /var/log/blog:/app/logs # Next.js Frontend frontend: build: context: .. - dockerfile: deploy/Dockerfile + dockerfile: infra/Dockerfile target: frontend-prod container_name: blog_frontend restart: unless-stopped + env_file: ../.env environment: NODE_ENV: production + PORT: 3001 NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost/api} expose: - "3001" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 66837fd..2bb661a 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -1,4 +1,5 @@ -version: '3.8' +# Development Docker Compose - for local development with hot reload +# Usage: pnpm docker:dev services: # PostgreSQL Database @@ -6,16 +7,17 @@ services: image: postgres:17-alpine container_name: blog_postgres_dev restart: unless-stopped + env_file: ../.env environment: - POSTGRES_USER: blog - POSTGRES_PASSWORD: postgres - POSTGRES_DB: blog_dev + POSTGRES_USER: ${DATABASE_USER:-blog} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-postgres} + POSTGRES_DB: ${DATABASE_NAME:-blog_dev} volumes: - postgres_data_dev:/var/lib/postgresql/data ports: - "5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U blog"] + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-blog} -d ${DATABASE_NAME:-blog_dev}"] interval: 10s timeout: 5s retries: 5 @@ -27,6 +29,7 @@ services: image: redis:7-alpine container_name: blog_redis_dev restart: unless-stopped + env_file: ../.env command: redis-server --appendonly yes volumes: - redis_data_dev:/data @@ -42,61 +45,48 @@ services: # NestJS Backend API api: - build: - context: .. - dockerfile: deploy/Dockerfile - target: backend-prod + image: node:24-alpine container_name: blog_api_dev restart: unless-stopped + working_dir: /workspace + command: sh -c "corepack enable && pnpm --filter api run dev" + env_file: ../.env depends_on: postgres: condition: service_healthy redis: condition: service_healthy environment: + CI: "true" NODE_ENV: development - PORT: 3000 - DATABASE_URL: postgresql://blog:postgres@postgres:5432/blog_dev + DATABASE_URL: postgresql://${DATABASE_USER:-blog}:${DATABASE_PASSWORD:-postgres}@postgres:${DATABASE_PORT:-5432}/${DATABASE_NAME:-blog_dev} REDIS_URL: redis://redis:6379 - API_PORT: 3000 + API_PORT: ${API_PORT:-3001} LOG_LEVEL: debug ports: - - "3000:3000" + - "3001:3001" volumes: - - ../apps/api/src:/app/apps/api/src + - ..:/workspace networks: - blog_network # Next.js Frontend frontend: - build: - context: .. - dockerfile: deploy/Dockerfile - target: frontend-prod + image: node:24-alpine container_name: blog_frontend_dev restart: unless-stopped + working_dir: /workspace + command: sh -c "corepack enable && pnpm --filter frontend exec next dev -p 3000" + env_file: ../.env environment: + CI: "true" NODE_ENV: development - NEXT_PUBLIC_API_URL: http://localhost:3000 + NEXT_PUBLIC_API_URL: http://localhost:3001 + PORT: 3000 ports: - - "3001:3001" + - "3000:3000" volumes: - - ../apps/frontend/src:/app/apps/frontend/src - networks: - - blog_network - - # Nginx Reverse Proxy - nginx: - image: nginx:alpine - container_name: blog_nginx_dev - restart: unless-stopped - depends_on: - - api - - frontend - ports: - - "80:80" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ..:/workspace networks: - blog_network diff --git a/package.json b/package.json index 71effbb..1297f9a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,13 @@ "dev": "turbo run dev --filter='./apps/*'", "format": "turbo run format", "lint": "turbo run lint", - "test": "turbo run test" + "test": "turbo run test", + "docker:dev": "docker compose -f infra/docker-compose.yml up", + "docker:dev:build": "docker compose -f infra/docker-compose.yml build", + "docker:prod": "docker compose -f infra/docker-compose.prod.yml up -d", + "docker:prod:build": "docker compose -f infra/docker-compose.prod.yml build", + "docker:down": "docker compose -f infra/docker-compose.yml down", + "docker:prod:down": "docker compose -f infra/docker-compose.prod.yml down" }, "keywords": [], "author": "",