Why
---
The dev compose (docker-compose.yml) assumes the Docker daemon can bind-mount
the host repo (and several subpaths) at runtime, providing live source for
`gradle :backend:bootRun` and Vite HMR. That works on a normal Linux/macOS
host but breaks in:
- Docker-in-Docker setups (e.g. the Hermes sandbox used for agent work)
- rootless Docker with restricted mount paths
- some CI runners
The failure mode is the daemon's mount namespace only sees compose-created
named-volume subdirs at the bind source, not the real repo files. The
backend then fails with `stat ./gradlew: no such file or directory` and
the frontend fails with `mount src=.../index.html, dst=.../index.html
... not a directory`. The image itself is empty of source — there are no
`COPY` lines in the dev Dockerfiles.
Approach
--------
Make the dev images self-sufficient by COPYing the source at build time.
The compose bind mount is kept (it's still the right thing for normal
local dev with HMR), but it's no longer load-bearing. The image works
standalone in any environment.
Add a separate `docker-compose.dev-bindless.yml` for environments where
host bind mounts can't be used (DinD, CI, restricted Docker). It uses
the same images (COPY'd source) but redefines the services with no
host bind mounts — only the named cache volumes remain, so gradle and
Vite caches persist between `up` cycles.
Compose merge semantics caveat: `volumes:` lists merge by concatenation,
not by entry replacement, so the bindless workflow can't be expressed as
a compose override on top of docker-compose.yml. A standalone file is
required.
Changes
-------
* docker/backend.Dockerfile
- Add `COPY gradlew settings.gradle build.gradle ./`
- Add `COPY gradle/ gradle/`
- Add `RUN chmod +x gradlew`
- Add `COPY backend/ backend/`
- Add `EXPOSE 8080`
- Keep ENTRYPOINT unchanged.
- New image is runnable with `docker run bilhej-backend-dev` (no bind
mount needed) and works under `docker compose up -d` on any host.
* docker/frontend.Dockerfile
- Add comments documenting the two-stage COPY pattern (deps first for
layer cache, then full source).
- Keep the existing structure — it already COPYs the source, just
wasn't being relied on. Now bind-mount failures (e.g. index.html
type mismatch in DinD) don't kill the container; the COPY'd file
is already in place.
- Add `EXPOSE 3000` (was missing).
* .dockerignore
- Expand to exclude everything that isn't strictly needed at build or
run time: docs, scripts, git, editor config, build outputs, test
results, logs, env files, docker-related metadata, etc.
- Cuts the build context from ~MBs to ~800 KB (verified).
- Image contents are now: gradlew + wrapper, build.gradle, settings,
gradle/, backend/ (for backend image); package.json, package-lock,
src/, public/, index.html, node_modules (for frontend image).
* docker-compose.dev-bindless.yml (new)
- Standalone variant of docker-compose.yml with all host bind mounts
removed. Same service definitions, same image tags, same env vars,
same named cache volumes (pgdata, gradle-cache, backend-gradle-
project, backend-build). Only differences: no `.:/app`, no
`./frontend/src:/app/src`, no `./frontend/public:/app/public`, no
`./frontend/index.html:/app/index.html`.
- Usage: `docker compose -f docker-compose.dev-bindless.yml up -d`
(no `--build` needed if images already exist; include `--build`
on first run or after pulling changes).
- Trade-off vs the default dev compose: image is "frozen" at build
time, so editing source on the host doesn't trigger HMR. Edit +
`docker compose up -d --build` (or just rebuild the relevant
service) to pick up changes. Named cache volumes still keep
gradle/npm caches warm across rebuilds.
* e2e compose (docker-compose.e2e.yml, docker/*.e2e.Dockerfile) —
unchanged. They were already self-contained and continue to work as
before. Verified by running the full 90/90 Playwright suite in 54s.
Compatibility with existing dev workflow
----------------------------------------
On a normal host where bind mounts work (the common case):
- `docker compose up -d` (the existing command) keeps working
unchanged. The bind mount on `.:/app` overlays the COPY'd source
at runtime, so HMR and `gradle :backend:bootRun` hot-reload work
exactly like before.
- Image size grows (~50 MB backend, ~50 MB frontend on top of base
image; ~200 MB including node_modules). Acceptable for dev.
- First-time `docker compose build` is slightly slower because it
has to COPY the source. Subsequent builds cache well: the COPY
layer invalidates only when source files change.
Verified
--------
- Hermes DinD sandbox: bindless dev stack (`docker-compose.dev-
bindless.yml`) brings up postgres + mailpit + backend + frontend
with no bind mounts. Spring Boot starts in ~6s, Vite dev server
in ~700ms. Backend serves real API responses
(`GET /api/vehicles/ABC123 -> 404 Inget fordon hittades`).
- Hermes DinD sandbox: e2e stack runs all 90 Playwright tests in
~54s, identical to pre-patch behavior.
- Docker image self-sufficiency: `docker run --rm bilhej-backend-dev`
and `docker run --rm bilhej-frontend-dev` both work without any
bind mounts.
Refs: project AGENTS.md (Docker section, gradle check pre-commit).
|
||
|---|---|---|
| .forgejo/workflows | ||
| backend | ||
| docker | ||
| docs | ||
| frontend | ||
| gradle/wrapper | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| build.gradle | ||
| CODING_GUIDELINES.md | ||
| docker-compose.ci.yml | ||
| docker-compose.dev-bindless.yml | ||
| docker-compose.e2e.yml | ||
| docker-compose.prod.yml | ||
| docker-compose.yml | ||
| gradlew | ||
| README.md | ||
| REQUIREMENTS.md | ||
| settings.gradle | ||
BilHej / Bilhälsning.se
Send a physical letter to a Swedish car owner — just by knowing their license plate.
The user enters a registration number, composes a letter (from a template or free text), pays, and BilHej handles the rest: owner address lookup via Transportstyrelsen, printing and mailing via PostNord. The sender never sees the recipient's name or address.
Tech Stack
| Layer | Technology |
|---|---|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia |
| Backend | Java 21, Spring Boot 4, Gradle |
| Database | PostgreSQL 16 |
| Auth | Spring Security + JWT |
| Payments | Stripe (cards + Swish) |
| Deployment | Docker, Docker Compose |
Prerequisites
- Docker & Docker Compose
- Java 21 (for local IDE development)
- Node.js 20+ (for local frontend dev)
- A Stripe account (test mode for development)
Quick Start
git clone <repo-url> bilhej
cd bilhej
cp .env.example .env # fill in your keys
docker compose up -d # or: ./gradlew up
The app will be available at:
- Frontend:
http://localhost:3000 - Backend API:
http://localhost:8080 - PostgreSQL:
localhost:5432 - Mailpit (dev SMTP inbox):
http://localhost:8025
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 │
│ └──────────────────┘
│ ┌──────────────────┐
│ │ mailpit │
│ │ SMTP :1025 │
│ │ UI :8025 │
│ └──────────────────┘
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 :backend:bootRun) |
docker |
PostgreSQL + dev Flyway seeds | Docker Compose dev / CI |
docker,prod |
PostgreSQL, schema only, admin bootstrap | Deploy (docker-compose.prod.yml) |
Environment Variables
Copy .env.example to .env and fill in:
| Variable | Description |
|---|---|
POSTGRES_DB |
Database name (default: bilhej) |
POSTGRES_USER |
Database user |
POSTGRES_PASSWORD |
Database password |
JWT_SECRET |
Secret key for JWT signing |
STRIPE_SECRET_KEY |
Stripe secret key |
STRIPE_WEBHOOK_SECRET |
Stripe webhook signing secret |
STRIPE_PRICE_ID |
Stripe price ID for single letter |
SWISH_NUMBER |
Swish number for payment instructions |
APP_PUBLIC_BASE_URL |
Base URL for password-reset links (dev: http://localhost:3000) |
MAIL_HOST |
SMTP host (Docker dev uses mailpit automatically; leave empty to log links only) |
MAIL_PORT |
SMTP port (1025 for Mailpit, 587 for most providers) |
MAIL_USERNAME |
SMTP username (empty for Mailpit) |
MAIL_PASSWORD |
SMTP password (empty for Mailpit) |
MAIL_FROM |
From address (e.g. noreply@bilhej.se) |
ADMIN_EMAIL |
Production admin login (e.g. admin@bilhej.se) |
ADMIN_PASSWORD |
Strong production admin password (not test1234) |
Dev-only accounts (from db/dev-migration, not used in production):
| Password | Role | |
|---|---|---|
test@bilhej.se |
test1234 |
user (e2e / local) |
admin@bilhalsning.se |
test1234 |
admin (local docker only) |
Database access
PostgreSQL runs in Docker. Any GUI client works (IntelliJ IDEA, DBeaver, TablePlus, pgAdmin) — same idea as a normal remote database.
Local dev (docker compose up)
| Setting | Value |
|---|---|
| Host | localhost |
| Port | 5432 |
| Database | from .env → POSTGRES_DB (default bilhej) |
| User / password | POSTGRES_USER / POSTGRES_PASSWORD |
IntelliJ IDEA: Database tool window → + → Data Source → PostgreSQL → fill in above →
Test Connection → OK.
CLI:
docker exec -it bilhej-postgres psql -U bilhej -d bilhej
Production (server)
Postgres is bound to localhost only on the server (127.0.0.1:5433) so it is not
exposed to the internet. Use an SSH tunnel from your laptop, then point IntelliJ (or
DBeaver) at localhost.
-
On the server, recreate the stack once so the port mapping is active (after deploy).
-
From your laptop:
ssh -N -L 5433:127.0.0.1:5433 you@srvr.nu
- In IntelliJ / DBeaver:
| Setting | Value |
|---|---|
| Host | localhost |
| Port | 5433 |
| Database | prod POSTGRES_DB |
| User / password | prod secrets |
CLI on the server (no GUI):
docker exec -it bilhej-postgres-prod psql -U bilhej -d bilhej
Manual prod cleanup (keep data, remove dev seeds)
DELETE FROM orders
WHERE user_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
DELETE FROM users
WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se');
Then deploy with ADMIN_EMAIL / ADMIN_PASSWORD set — the app creates the production
admin on startup. No need to insert a password hash by hand.
Email (password reset)
The app only needs to send mail (forgot-password). You do not need Office 365 or a mailbox on
the server unless you want human addresses like support@bilhej.se.
Local Docker (Mailpit): docker compose up starts Mailpit
(ghcr.io/axllent/mailpit:v1.28). All outbound mail is captured—nothing is sent to the internet.
If docker compose pull fails on Docker Hub, pull explicitly:
docker pull ghcr.io/axllent/mailpit:v1.28
- Open http://localhost:8025
- Use Glömt lösenord? on the login page (or Byt lösenord in the header when logged in)
- Open the message in Mailpit and click the reset link
To disable Mailpit and log links only, remove MAIL_HOST from the backend service in
docker-compose.yml or set MAIL_HOST= in .env.
Production: Resend via SMTP (no Resend Java SDK required). See docs/production-email-checklist.md.
- Verify domain bilhej.se in Resend (SPF + DKIM DNS records)
- Create an API key (
re_...) - On the production server
.env:
APP_PUBLIC_BASE_URL=https://bilhej.se
MAIL_HOST=smtp.resend.com
MAIL_PORT=587
MAIL_USERNAME=resend
MAIL_PASSWORD=re_xxxxxxxx
MAIL_FROM=noreply@bilhej.se
MAIL_USERNAME is always the literal string resend; MAIL_PASSWORD is the API key.
- Deploy via Deploy to Production, then test forgot-password on https://bilhej.se
See docs/production-email-checklist.md for a step-by-step operator checklist.
If SMTP is not configured (MAIL_HOST empty), the reset link is written to the backend log:
docker logs bilhej-backend-prod 2>&1 | grep "Password reset link"
Optional later: real inboxes (support@bilhej.se) via Migadu, Fastmail, or Purelymail—separate
from app MAIL_* (different MX records).
To generate a bcrypt hash manually (optional):
./gradlew hashPassword -Ppassword='your-strong-password'
Project Structure
bilhej/
├── frontend/ # Vue.js 3 SPA
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ ├── composables/ # Shared composition functions
│ │ ├── layouts/ # Page layouts
│ │ ├── pages/ # Route-level page components
│ │ ├── router/ # Vue Router config
│ │ ├── stores/ # Pinia stores
│ │ ├── api/ # API client and endpoints
│ │ ├── assets/ # Static assets, CSS
│ │ ├── App.vue
│ │ └── main.ts
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
├── backend/ # Spring Boot 4
│ ├── src/main/java/se/bilhalsning/
│ │ ├── BilHejApplication.java
│ │ ├── config/ # Security, CORS, Stripe config
│ │ ├── controller/ # REST controllers
│ │ ├── dto/ # Data transfer objects
│ │ ├── entity/ # JPA entities
│ │ ├── repository/ # Spring Data repositories
│ │ ├── service/ # Business logic
│ │ └── security/ # JWT filter, user details
│ └── src/main/resources/
│ ├── 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 # dev: JDK + gradle :backend: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
├── gradlew # Gradle wrapper (run from repo root)
├── gradle/
│ └── wrapper/
├── settings.gradle # rootProject.name + include 'backend'
├── build.gradle # convenience tasks: check, up, down, reset
├── .env.example
├── README.md
├── REQUIREMENTS.md
└── CODING_GUIDELINES.md
Development vs Production
| Aspect | docker compose up -d |
docker compose -f docker-compose.prod.yml up -d |
|---|---|---|
| Backend | ./gradlew :backend: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 |
Production Deployment
Deployments are fully automated via Forgejo Actions. The pipeline builds production Docker images and starts them on the server.
One-time Setup
Before the first deploy, complete these steps on the production server (srvr.nu):
-
Add Forgejo Actions Secrets
Go to Forgejo → Repository Settings → Actions → Secrets and add:
Secret Description POSTGRES_DBDatabase name (e.g., bilhej)POSTGRES_USERDatabase user POSTGRES_PASSWORDStrong database password JWT_SECRETopenssl rand -hex 32STRIPE_SECRET_KEYStripe secret key STRIPE_WEBHOOK_SECRETStripe webhook signing secret STRIPE_PRICE_IDStripe price ID for single letter SWISH_NUMBERSwish phone number for payment instructions ADMIN_EMAILProduction admin email (e.g. admin@bilhej.se)ADMIN_PASSWORDStrong unique admin password (password manager) APP_PUBLIC_BASE_URLhttps://bilhej.se(password-reset links)MAIL_HOSTsmtp.resend.comMAIL_PORT587MAIL_USERNAMEresend(literal string)MAIL_PASSWORDResend API key ( re_...; rotate if ever exposed)MAIL_FROMnoreply@bilhej.se(must be on verified domain)VITE_UMAMI_WEBSITE_IDUmami website UUID for bilhej.se(seedocs/umami-analytics.md)Passwords may contain
$— the deploy workflow escapes these for Docker Compose. Production does not seedtest@bilhej.seor demo orders. On first start, the backend creates one admin fromADMIN_EMAIL/ADMIN_PASSWORDif no admin exists.If prod already has dev seed users, clean them with SQL (see Database access) instead of wiping the volume. Then redeploy with the new secrets so bootstrap can create
ADMIN_EMAIL. -
Point DNS
Set
bilhej.se(andwww.bilhej.se) A record to the server's public IP. -
Add HTTP-only Nginx vhost (required before certs exist)
The full
docker/bilhej.nginx.confreferences TLS files that do not exist yet. Deploy the HTTP-only config first:docker cp docker/bilhej.nginx.http.conf nginx:/etc/nginx/conf.d/bilhej.conf docker exec nginx nginx -t docker exec nginx nginx -s reload -
Obtain SSL Certificate
docker exec certbot certbot certonly \ --webroot -w /var/www/certbot \ -d bilhej.se -d www.bilhej.se -
Enable HTTPS proxy to the frontend
docker cp docker/bilhej.nginx.conf nginx:/etc/nginx/conf.d/bilhej.conf docker exec nginx nginx -t docker exec nginx nginx -s reload
Deploy
- Go to Actions → Deploy to Production in Forgejo.
- Click Run workflow.
- Fill in both fields (Forgejo requires
typeon inputs — seedeploy.yml):- Use workflow from:
master(which commit to build). Do not confuse this with the deploy tag below. - Version tag: label created by the pipeline (e.g.
v0.1.2). Change this each release; defaultv0.1.0is only a placeholder.
- Use workflow from:
- Click Run workflow.
Deploy failed (backend health check)
If the job passes the frontend check but the backend never becomes healthy:
- Open the failed job log and read Backend logs (printed before rollback).
- Match the error to a fix — do not guess:
Detected applied migration not resolved locally: 6(or 2, 4) — prod DB still lists dev seed migrations removed fromdb/migration. Fixed in app viaProdFlywayConfig(repair before migrate); redeploy after that commit is onmaster.password authentication failed— DB credentials in the running stack do not match what Postgres was initialized with; fix credentials or Postgres password to match (only wipe the volume if you accept losing prod data).Production requires ADMIN_EMAIL and ADMIN_PASSWORD— add those Forgejo secrets.- Other Flyway / migration errors — read the stack trace; do not wipe the volume unless the log clearly requires it.
- DBeaver from your laptop — prod Postgres binds to
127.0.0.1:5433on the server only. Use an SSH tunnel, then hostlocalhostport5433(not192.168.0.59directly).
What Happens
| Step | Action |
|---|---|
| Tag | Git tag v0.1.0 is created and pushed |
| Build | Production backend JAR and frontend bundle are built |
| Images | Multi-stage Docker images are built locally on the server |
| Start | docker compose -f docker-compose.prod.yml up -d (SPRING_PROFILES_ACTIVE=docker,prod) |
| Verify | Health checks confirm backend API and frontend are responding |
Architecture on Server
User
│
│ https://bilhej.se
▼
┌─────────────────────────────────────┐
│ nginx (srvr.nu) │
│ SSL termination (Let's Encrypt) │
│ proxy_pass → bilhej-frontend-prod │
└─────────────┬───────────────────────┘
│ Docker 'web' network
┌─────────────▼───────────────────────┐
│ bilhej-frontend-prod (nginx) │
│ :80 │
│ /api/* → bilhej-backend-prod:8080 │
└─────────────┬───────────────────────┘
│
┌─────────────▼───────────────────────┐
│ bilhej-backend-prod (Spring Boot) │
│ :8080 │
└─────────────┬───────────────────────┘
│
┌─────────────▼───────────────────────┐
│ bilhej-postgres-prod (PostgreSQL) │
│ :5432 │
└─────────────────────────────────────┘
Rollback
To rollback to a previous version:
# On srvr.nu
cd /path/to/bilhej/repo
git fetch --tags
git checkout v0.1.0 # or any previous tag
docker compose -f docker-compose.prod.yml up --build -d
Development
All-in-one (from repo root)
./gradlew check # lint → frontend test → backend test → integration test
./gradlew up # docker compose up -d
./gradlew down # docker compose down
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
Frontend (dev server with HMR)
cd frontend
npm install # first time only
npm run dev # :3000 with HMR
Backend (IDE or CLI)
./gradlew :backend:bootRun # :8080, profile: default (H2)
Stripe Webhooks (local testing)
stripe listen --forward-to localhost:8080/api/webhooks/stripe
Database reset
./gradlew reset # wipes DB volume and restarts containers
Related Documents
- REQUIREMENTS.md — Full product requirements and business model
- CODING_GUIDELINES.md — Code conventions and standards