feat: add Docker Compose setup with dev and prod configurations

- 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.
This commit is contained in:
Joakim Mörling 2026-05-01 01:45:07 +02:00
parent 9931061cb6
commit 4d449d54d0
14 changed files with 293 additions and 15 deletions

1
.gitignore vendored
View file

@ -37,6 +37,7 @@ Thumbs.db
# Docker
docker-compose.override.yml
certs/
# Java
*.hprof

View file

@ -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
│ ├── 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

View file

@ -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
│ ├── application.yml # default profile (H2)
│ ├── application-docker.yml # docker profile (PostgreSQL)
│ └── db/migration/ # Flyway migrations
├── docker-compose.yml
├── 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)

View file

@ -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'

View file

@ -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

57
docker-compose.prod.yml Normal file
View file

@ -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:

58
docker-compose.yml Normal file
View file

@ -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:

View file

@ -0,0 +1,3 @@
FROM eclipse-temurin:21-jdk
WORKDIR /app
ENTRYPOINT ["./gradlew", "bootRun", "--no-daemon"]

View file

@ -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"]

10
docker/entrypoint.sh Normal file
View file

@ -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 "$@"

View file

@ -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"]

View file

@ -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;"]

30
docker/nginx.conf Normal file
View file

@ -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;
}

View file

@ -12,6 +12,9 @@ export default defineConfig({
},
server: {
port: 3000,
proxy: {
'/api': 'http://backend:8080',
},
},
test: {
environment: 'jsdom',