From 4d449d54d05591e8b79b2bb0be3df2d3cc58705c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 1 May 2026 01:45:07 +0200 Subject: [PATCH] feat: add Docker Compose setup with dev and prod configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml (dev): 3 services — postgres:16, backend (gradle bootRun with JDK 21, spring-boot-devtools), frontend (Vite HMR on node:24-alpine). Source volume mounts for live editing, Gradle cache volume for fast rebuilds, pg_isready healthcheck on postgres. - docker-compose.prod.yml (prod): same 3 services but with multi-stage Dockerfiles. Backend: Gradle bootJar → JRE Alpine, non-root user. Frontend: npm ci + vite build → nginx:alpine serving static dist/. SSL termination via self-signed cert (auto-generated in entrypoint). No source mounts, restart: unless-stopped, separate volumes. - application-docker.yml: Spring profile overriding H2 with PostgreSQL via env vars. Hostname "postgres" resolved by Docker Compose DNS. - Vite proxy /api → backend:8080 for dev. nginx nginx.conf handles /api proxy + SPA fallback + gzip + SSL in prod. - AGENTS.md, README.md: architecture diagram, dev vs prod comparison table, Spring profiles docs, file reference updates. --- .gitignore | 1 + AGENTS.md | 19 +++-- README.md | 74 ++++++++++++++++--- backend/build.gradle | 1 + .../src/main/resources/application-docker.yml | 13 ++++ docker-compose.prod.yml | 57 ++++++++++++++ docker-compose.yml | 58 +++++++++++++++ docker/backend.Dockerfile | 3 + docker/backend.prod.Dockerfile | 16 ++++ docker/entrypoint.sh | 10 +++ docker/frontend.Dockerfile | 7 ++ docker/frontend.prod.Dockerfile | 16 ++++ docker/nginx.conf | 30 ++++++++ frontend/vite.config.ts | 3 + 14 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/resources/application-docker.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docker/backend.Dockerfile create mode 100644 docker/backend.prod.Dockerfile create mode 100644 docker/entrypoint.sh create mode 100644 docker/frontend.Dockerfile create mode 100644 docker/frontend.prod.Dockerfile create mode 100644 docker/nginx.conf diff --git a/.gitignore b/.gitignore index a5141f0..19a5b58 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ Thumbs.db # Docker docker-compose.override.yml +certs/ # Java *.hprof diff --git a/AGENTS.md b/AGENTS.md index e31b472..101b5f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ the recipient's name or address. integration yet. Owner address is obtained manually by a human and entered into the admin panel. -Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 3 backend + +Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 4 backend + PostgreSQL 16. Deployed via Docker Compose. --- @@ -80,7 +80,7 @@ bilhej/ │ │ ├── router/ # Vue Router config │ │ └── assets/ # Static files, CSS │ └── ... -├── backend/ # Spring Boot 3 (Java 21) +├── backend/ # Spring Boot 4 (Java 21) │ ├── src/main/java/se/bilhalsning/ │ │ ├── config/ # @Configuration classes │ │ ├── controller/ # REST controllers @@ -92,11 +92,18 @@ bilhej/ │ │ ├── exception/ # Custom exceptions + @ControllerAdvice │ │ └── mapper/ # Entity ↔ DTO mapping │ └── src/main/resources/ -│ ├── application.yml -│ └── db/migration/ # Flyway migrations +│ ├── application.yml # default (H2, IDE dev) +│ ├── application-docker.yml # docker profile (PostgreSQL) +│ └── db/migration/ # Flyway migrations ├── docker/ # Dockerfiles -├── docker-compose.yml -├── docker-compose.prod.yml +│ ├── backend.Dockerfile # dev: JDK 21 + gradle bootRun +│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle build → JRE Alpine, non-root) +│ ├── frontend.Dockerfile # dev: Node 24 + vite dev server +│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node build → nginx) +│ ├── nginx.conf # prod: SPA fallback + /api reverse proxy +│ └── entrypoint.sh # prod: self-signed cert generation +├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR) +├── docker-compose.prod.yml # prod: multi-stage images, no source mounts, restart always ├── .env.example ├── AGENTS.md # This file ├── README.md diff --git a/README.md b/README.md index 633395c..d3c93ff 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The user enters a registration number, composes a letter (from a template or fre | Layer | Technology | |-------------|-----------------------------------------| | Frontend | Vue.js 3 (Composition API), Vite, Pinia | -| Backend | Java 21, Spring Boot 4 | +| Backend | Java 21, Spring Boot 4, Gradle | | Database | PostgreSQL 16 | | Auth | Spring Security + JWT | | Payments | Stripe (cards + Swish) | @@ -42,6 +42,40 @@ The app will be available at: - Backend API: `http://localhost:8080` - PostgreSQL: `localhost:5432` +### Architecture inside Docker Compose + +``` + Browser Docker Compose network + ─────── ───────────────────── + │ ┌──────────────────┐ + │ http://localhost:3000 │ frontend (Vite) │ + ├────────────────────────→│ :3000 │ + │ │ proxy: /api → │ + │ GET /api/orders │ backend:8080 │ + │ └────────┬─────────┘ + │ │ + │ ┌────────▼─────────┐ + │ │ backend (Spring) │ + │ │ :8080 │ + │ │ profile: docker │ + │ └────────┬─────────┘ + │ │ + │ ┌────────▼─────────┐ + │ │ postgres (16) │ + │ │ :5432 │ + │ └──────────────────┘ +``` + +**Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container. +No CORS configuration needed in development — the browser never calls the backend directly. + +**Spring profiles:** +| Profile | Datasource | Use | +|---------|-----------|-----| +| default | H2 in-memory | Local IDE dev (`./gradlew bootRun`) | +| `docker` | PostgreSQL via `docker-compose.yml` | Docker Compose dev | +| `prod` | PostgreSQL (production config) | Deploy (`docker-compose.prod.yml`) | + --- ## Environment Variables @@ -75,11 +109,11 @@ bilhej/ │ │ ├── api/ # API client and endpoints │ │ ├── assets/ # Static assets, CSS │ │ ├── App.vue -│ │ └── main.js +│ │ └── main.ts │ ├── index.html -│ ├── vite.config.js +│ ├── vite.config.ts │ └── package.json -├── backend/ # Spring Boot 3 +├── backend/ # Spring Boot 4 │ ├── src/main/java/se/bilhalsning/ │ │ ├── BilHejApplication.java │ │ ├── config/ # Security, CORS, Stripe config @@ -90,12 +124,18 @@ bilhej/ │ │ ├── service/ # Business logic │ │ └── security/ # JWT filter, user details │ └── src/main/resources/ -│ ├── application.yml -│ └── db/migration/ # Flyway migrations -├── docker-compose.yml +│ ├── application.yml # default profile (H2) +│ ├── application-docker.yml # docker profile (PostgreSQL) +│ └── db/migration/ # Flyway migrations +├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR) +├── docker-compose.prod.yml # prod: multi-stage builds, no source mounts, restart: unless-stopped ├── docker/ -│ ├── backend.Dockerfile -│ └── frontend.Dockerfile +│ ├── backend.Dockerfile # dev: JDK + gradle bootRun +│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle → JRE Alpine, non-root) +│ ├── frontend.Dockerfile # dev: Node + vite dev server +│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node → nginx) +│ ├── nginx.conf # prod: SPA fallback + /api proxy +│ └── entrypoint.sh # prod: self-signed cert generation ├── .env.example ├── README.md ├── REQUIREMENTS.md @@ -105,6 +145,22 @@ bilhej/ --- +## Development vs Production + +| Aspect | `docker compose up -d` | `docker compose -f docker-compose.prod.yml up -d` | +|--------|------------------------|---------------------------------------------------| +| Backend | `./gradlew bootRun` (compiles on change) | Multi-stage build → `java -jar app.jar` | +| Backend image | `eclipse-temurin:21-jdk` (~400 MB) | `eclipse-temurin:21-jre-alpine` (~200 MB) | +| Backend user | root | `bilhej` (non-root) | +| Frontend | Vite dev server (HMR, `--host 0.0.0.0`) | nginx serving static `dist/` | +| API proxy | Vite built-in proxy (`/api` → `backend:8080`) | nginx `proxy_pass` | +| SSL | None | Self-signed cert auto-generated on first start (`.certs/` volume) | +| Source mounts | Yes (live edit) | No (files baked into image) | +| Restart policy | Manual | `unless-stopped` | +| Purpose | Write code, instant feedback | Verify production build | + +--- + ## Development ### Frontend (dev server with HMR) diff --git a/backend/build.gradle b/backend/build.gradle index 2604809..bb2c802 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -24,6 +24,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.flywaydb:flyway-database-postgresql' + developmentOnly 'org.springframework.boot:spring-boot-devtools' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.postgresql:postgresql' diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml new file mode 100644 index 0000000..b4bdb92 --- /dev/null +++ b/backend/src/main/resources/application-docker.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:postgresql://postgres:5432/${POSTGRES_DB} + driver-class-name: org.postgresql.Driver + username: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + + h2: + console: + enabled: false + + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..f8f1c72 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,57 @@ +services: + postgres: + image: postgres:16 + container_name: bilhej-postgres-prod + ports: + - "5432:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata-prod:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + backend: + build: + dockerfile: docker/backend.prod.Dockerfile + context: . + container_name: bilhej-backend-prod + ports: + - "8080:8080" + environment: + SPRING_PROFILES_ACTIVE: docker + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + STRIPE_PRICE_ID: ${STRIPE_PRICE_ID} + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + frontend: + build: + dockerfile: docker/frontend.prod.Dockerfile + context: . + container_name: bilhej-frontend-prod + ports: + - "3000:80" + - "443:443" + depends_on: + - backend + volumes: + - certs:/etc/nginx/certs + restart: unless-stopped + +volumes: + pgdata-prod: + certs: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..444d45b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + postgres: + image: postgres:16 + container_name: bilhej-postgres + ports: + - "5432:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + dockerfile: docker/backend.Dockerfile + context: . + container_name: bilhej-backend + ports: + - "8080:8080" + environment: + SPRING_PROFILES_ACTIVE: docker + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + STRIPE_PRICE_ID: ${STRIPE_PRICE_ID} + depends_on: + postgres: + condition: service_healthy + volumes: + - ./backend:/app + - gradle-cache:/root/.gradle + + frontend: + build: + dockerfile: docker/frontend.Dockerfile + context: . + container_name: bilhej-frontend + ports: + - "3000:3000" + depends_on: + - backend + volumes: + - ./frontend/src:/app/src + - ./frontend/public:/app/public + - ./frontend/index.html:/app/index.html + +volumes: + pgdata: + gradle-cache: diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile new file mode 100644 index 0000000..0d73282 --- /dev/null +++ b/docker/backend.Dockerfile @@ -0,0 +1,3 @@ +FROM eclipse-temurin:21-jdk +WORKDIR /app +ENTRYPOINT ["./gradlew", "bootRun", "--no-daemon"] diff --git a/docker/backend.prod.Dockerfile b/docker/backend.prod.Dockerfile new file mode 100644 index 0000000..5e425bb --- /dev/null +++ b/docker/backend.prod.Dockerfile @@ -0,0 +1,16 @@ +FROM eclipse-temurin:21-jdk AS builder +WORKDIR /app +COPY backend/gradlew ./ +COPY backend/gradle/ ./gradle/ +COPY backend/build.gradle backend/settings.gradle ./ +RUN chmod +x gradlew && ./gradlew dependencies --no-daemon -q +COPY backend/src ./src +RUN ./gradlew bootJar --no-daemon -q + +FROM eclipse-temurin:21-jre-alpine +RUN addgroup -S bilhej && adduser -S bilhej -G bilhej +WORKDIR /app +COPY --from=builder /app/build/libs/*-SNAPSHOT.jar ./app.jar +USER bilhej +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..870466c --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +CERT_DIR="/etc/nginx/certs" +if [ ! -f "$CERT_DIR/cert.crt" ] || [ ! -f "$CERT_DIR/cert.key" ]; then + mkdir -p "$CERT_DIR" + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$CERT_DIR/cert.key" \ + -out "$CERT_DIR/cert.crt" \ + -subj "/CN=localhost" +fi +exec /docker-entrypoint.sh "$@" diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile new file mode 100644 index 0000000..a1b4d06 --- /dev/null +++ b/docker/frontend.Dockerfile @@ -0,0 +1,7 @@ +FROM node:24-alpine +WORKDIR /app +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm install +COPY frontend/ . +EXPOSE 3000 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/docker/frontend.prod.Dockerfile b/docker/frontend.prod.Dockerfile new file mode 100644 index 0000000..23450d7 --- /dev/null +++ b/docker/frontend.prod.Dockerfile @@ -0,0 +1,16 @@ +FROM node:24-alpine AS builder +WORKDIR /app +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ . +RUN npm run build + +FROM nginx:alpine +RUN apk add --no-cache openssl +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +EXPOSE 80 443 +ENTRYPOINT ["/entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..db4c5f2 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/certs/cert.crt; + ssl_certificate_key /etc/nginx/certs/cert.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8080; + 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 / { + try_files $uri $uri/ /index.html; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; + gzip_vary on; + gzip_min_length 256; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 05a3826..4e97eef 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ }, server: { port: 3000, + proxy: { + '/api': 'http://backend:8080', + }, }, test: { environment: 'jsdom',