Migratie naar 2-container opzet (app + database)

De Express-app serveert nu zelf de gebouwde Vite-frontend op / met een
SPA-fallback naar index.html; de aparte nginx-container is vervallen.
Bedoeld voor self-hosting op een Raspberry Pi: één image, één origin,
geen interne proxy en geen CORS tussen frontend en API.

Docker
- Nieuwe multi-stage Dockerfile in de root met targets test, development
  en production; frontend/Dockerfile, frontend/nginx-spa.conf en
  backend/Dockerfile verwijderd
- docker-compose.yml: alleen app + postgres, app publiceert APP_PORT
  (standaard 3400) naar containerpoort 3001
- docker-compose.dev.yml: dev-container draait API (node --watch) en de
  Vite dev-server samen via docker/dev-entrypoint.sh, hot reload intact
- .dockerignore, .env.example, .gitignore en .gitattributes toegevoegd

Backend
- express.static + SPA-fallback op STATIC_DIR (default /app/public), met
  immutable caching voor gehashte assets en no-store voor index.html
- Rate limiting alleen nog op /api — statische bestanden liepen anders na
  200 requests tegen de limiet aan
- Helmet CSP toegespitst op wat de frontend laadt (Google Fonts, inline
  styles, base64-avatars); upgrade-insecure-requests uit voor intern http
- Schema-init verplaatst naar initDb(), niet meer bij import van db.js

Tests en CI/CD
- backend/test/app.test.js: 11 tests (node --test) voor API-routes,
  SPA-fallback, cache- en security-headers, zonder database
- test.ps1 / test.sh: backend-tests, productiebuild en smoketest op de
  echte app + postgres stack
- azure-pipelines.yml: Test -> Build (incl. smoketest) -> Publish
  (multi-arch amd64 + arm64, alleen op main/master of een tag)

Scripts en documentatie
- dev.ps1/dev.sh met targets app en postgres, acties logs en test;
  dev.ps1 up wist de dev-database niet langer (alleen dev.ps1 down)
- release.ps1/release.sh bouwen één image voor amd64 + arm64 en draaien
  de testsuite vóór het publiceren
- README.md en BEHEER.md bijgewerkt voor de nieuwe structuur

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordy Hoogeveen
2026-09-04 11:51:29 +02:00
co-authored by Claude Opus 5
parent 3c67381de9
commit bfec3a25f3
29 changed files with 1168 additions and 428 deletions
-41
View File
@@ -1,41 +0,0 @@
# ╔══════════════════════════════════════════════╗
# ║ Backend Dockerfile – multi-stage ║
# ╚══════════════════════════════════════════════╝
# ── Stage 1: deps (incl. native build tools) ─────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install && npm cache clean --force
# ── Stage 2: development (hot reload via --watch) ─
FROM deps AS development
COPY . .
EXPOSE 3001
CMD ["node", "--watch", "src/server.js"]
# ── Stage 3: productie-deps (apart gebouwd) ───────
FROM deps AS prod-deps
RUN npm prune --omit=dev
# ── Stage 4: production (minimale image) ──────────
FROM node:22-alpine AS production
LABEL maintainer="VibeFinance"
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
COPY src/ ./src/
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "src/server.js"]
+1
View File
@@ -5,6 +5,7 @@
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test",
"lint": "eslint src --ext .js"
},
"dependencies": {
+10
View File
@@ -1,4 +1,8 @@
import "dotenv/config";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const config = {
NODE_ENV: process.env.NODE_ENV || "development",
@@ -9,6 +13,12 @@ export const config = {
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL,
// Map met de gebouwde frontend (Vite dist). In het productie-image staat die
// op /app/public; bestaat de map niet, dan serveert de app alleen de API.
STATIC_DIR: process.env.STATIC_DIR
? resolve(process.env.STATIC_DIR)
: join(__dirname, "..", "..", "public"),
// Update-notificaties: welke release-API wordt gepolld (Gitea).
// Leeg laten schakelt de melding in de app uit.
UPDATE_CHECK_URL: process.env.UPDATE_CHECK_URL
+28 -21
View File
@@ -7,27 +7,34 @@ export const db = new Pool({
connectionString: config.DATABASE_URL,
});
await db.query(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
naam TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
wachtwoord TEXT NOT NULL,
rol TEXT NOT NULL DEFAULT 'Viewer',
actief BOOLEAN NOT NULL DEFAULT true,
aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
/**
* Zet het schema klaar. Wordt bij het starten van de server aangeroepen —
* niet bij het importeren, zodat de app ook zonder database te importeren is
* (tests, health checks tijdens de build).
*/
export async function initDb() {
await db.query(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
naam TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
wachtwoord TEXT NOT NULL,
rol TEXT NOT NULL DEFAULT 'Viewer',
actief BOOLEAN NOT NULL DEFAULT true,
aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_data (
user_id TEXT PRIMARY KEY,
data TEXT NOT NULL
);
`);
CREATE TABLE IF NOT EXISTS user_data (
user_id TEXT PRIMARY KEY,
data TEXT NOT NULL
);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW();
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW();
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;
`);
}
+61 -16
View File
@@ -3,9 +3,12 @@ import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import cookieParser from "cookie-parser";
import pino from "pino";
import pinoHttp from "pino-http";
import { existsSync } from "fs";
import { join } from "path";
import { config } from "./config/index.js";
import { logger } from "./logger.js";
import { initDb } from "./db.js";
import authRouter from "./routes/auth.js";
import dataRouter from "./routes/data.js";
import usersRouter from "./routes/users.js";
@@ -13,23 +16,36 @@ import healthRouter from "./routes/health.js";
import updateRouter from "./routes/update.js";
import { errorHandler } from "./middleware/errorHandler.js";
export const logger = pino({
level: config.NODE_ENV === "production" ? "info" : "debug",
...(config.NODE_ENV !== "production" && {
transport: { target: "pino-pretty", options: { colorize: true } },
}),
});
export { logger };
const app = express();
// ── Security ──────────────────────────────────────────────────────────────────
app.use(helmet());
// De app serveert nu ook de frontend, dus het CSP-beleid moet passen bij wat
// die pagina laadt: Google Fonts, inline <style> van React en base64-avatars.
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
// Uit: intern draait de app op http achter een reverse proxy.
upgradeInsecureRequests: null,
},
},
}));
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
app.set("trust proxy", 1);
// ── Rate limiting ─────────────────────────────────────────────────────────────
// ── Rate limiting (alleen op de API — niet op statische bestanden) ────────────
app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false }));
app.use("/api", rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false }));
// ── Parsing ───────────────────────────────────────────────────────────────────
app.use(express.json({ limit: "2mb" }));
@@ -40,23 +56,52 @@ if (config.NODE_ENV !== "test") {
app.use(pinoHttp({ logger }));
}
// ── Routes ────────────────────────────────────────────────────────────────────
app.use("/api/health", healthRouter);
// ── API-routes ────────────────────────────────────────────────────────────────
app.use("/api/health", healthRouter);
app.use("/api/update-check", updateRouter);
app.use("/api/auth", authRouter);
app.use("/api/data", dataRouter);
app.use("/api/users", usersRouter);
app.use("/api/auth", authRouter);
app.use("/api/data", dataRouter);
app.use("/api/users", usersRouter);
// ── 404 voor overige /api/* ───────────────────────────────────────────────────
app.use("/api/*", (_req, res) => res.status(404).json({ error: "Endpoint niet gevonden." }));
// ── Frontend (Vite build) ─────────────────────────────────────────────────────
const indexHtml = join(config.STATIC_DIR, "index.html");
export const servesFrontend = existsSync(indexHtml);
if (servesFrontend) {
// Assets hebben een hash in de naam en mogen lang gecached worden;
// index.html juist nooit, anders blijft een oude build hangen.
app.use(express.static(config.STATIC_DIR, {
index: false,
setHeaders(res, path) {
res.setHeader("Cache-Control", path.endsWith(".html")
? "no-store, no-cache, must-revalidate"
: "public, max-age=31536000, immutable");
},
}));
// SPA-fallback: alle overige GET-routes naar index.html.
app.get("*", (_req, res) => {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
res.sendFile(indexHtml);
});
} else {
logger.warn({ dir: config.STATIC_DIR }, "Geen frontend-build gevonden — alleen de API is beschikbaar");
}
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);
// ── Start ─────────────────────────────────────────────────────────────────────
if (config.NODE_ENV !== "test") {
await initDb();
app.listen(config.PORT, () => {
logger.info(`VibeFinance API gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]`);
logger.info(
`VibeFinance gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]` +
(servesFrontend ? " — API + frontend" : " — alleen API")
);
});
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Integratietests voor de gecombineerde app-container: de Express-app serveert
* zowel de API op /api als de gebouwde frontend op /.
*
* Draait zonder database: NODE_ENV=test slaat het schema-initialisatie en
* app.listen() in server.js over, en geen van de geteste routes raakt de db.
*/
import { after, before, describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const INDEX_MARKER = "<div id=\"root\"></div>";
const ASSET_BODY = "console.log('vibefinance');";
let server;
let basis;
before(async () => {
// Nagebootste Vite-build: index.html + een asset met hash in de naam.
const staticDir = mkdtempSync(join(tmpdir(), "vibefinance-static-"));
mkdirSync(join(staticDir, "assets"));
writeFileSync(join(staticDir, "index.html"), `<!DOCTYPE html><html><body>${INDEX_MARKER}</body></html>`);
writeFileSync(join(staticDir, "assets", "index-abc12345.js"), ASSET_BODY);
process.env.NODE_ENV = "test";
process.env.STATIC_DIR = staticDir;
process.env.UPDATE_CHECK_URL = ""; // geen uitgaande call in tests
process.env.JWT_SECRET = "test-secret";
const { default: app } = await import("../src/server.js");
server = app.listen(0);
await new Promise((res) => server.once("listening", res));
basis = `http://127.0.0.1:${server.address().port}`;
});
after(() => server?.close());
describe("API", () => {
it("health geeft status ok", async () => {
const res = await fetch(`${basis}/api/health`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.status, "ok");
assert.equal(typeof body.uptime, "number");
});
it("onbekende API-route geeft 404 als JSON, niet de SPA", async () => {
const res = await fetch(`${basis}/api/bestaat-niet`);
assert.equal(res.status, 404);
assert.match(res.headers.get("content-type") ?? "", /application\/json/);
assert.equal((await res.json()).error, "Endpoint niet gevonden.");
});
it("beschermde route zonder token geeft 401", async () => {
const res = await fetch(`${basis}/api/data`);
assert.equal(res.status, 401);
assert.equal((await res.json()).error, "Niet ingelogd.");
});
it("update-check zonder ingestelde URL meldt niets", async () => {
const res = await fetch(`${basis}/api/update-check`);
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.latest, null);
assert.equal(body.disabled, true);
});
});
describe("Frontend serveren", () => {
it("root geeft index.html", async () => {
const res = await fetch(`${basis}/`);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
assert.match(await res.text(), new RegExp(INDEX_MARKER));
});
it("onbekend pad valt terug op index.html (React-routing)", async () => {
const res = await fetch(`${basis}/voortgang`);
assert.equal(res.status, 200);
assert.match(await res.text(), new RegExp(INDEX_MARKER));
});
it("index.html wordt niet gecached", async () => {
const res = await fetch(`${basis}/`);
assert.match(res.headers.get("cache-control") ?? "", /no-store/);
});
it("assets met hash worden lang gecached", async () => {
const res = await fetch(`${basis}/assets/index-abc12345.js`);
assert.equal(res.status, 200);
assert.equal(await res.text(), ASSET_BODY);
assert.match(res.headers.get("cache-control") ?? "", /immutable/);
});
});
describe("Security headers", () => {
it("CSP staat Google Fonts en inline styles toe", async () => {
const res = await fetch(`${basis}/`);
const csp = res.headers.get("content-security-policy") ?? "";
assert.match(csp, /style-src [^;]*'unsafe-inline'/);
assert.match(csp, /fonts\.googleapis\.com/);
assert.match(csp, /font-src [^;]*fonts\.gstatic\.com/);
assert.doesNotMatch(csp, /upgrade-insecure-requests/);
});
it("statische bestanden krijgen geen rate-limit headers", async () => {
const res = await fetch(`${basis}/assets/index-abc12345.js`);
assert.equal(res.headers.get("ratelimit-limit"), null);
});
it("de API krijgt wel rate-limit headers", async () => {
const res = await fetch(`${basis}/api/health`);
assert.notEqual(res.headers.get("ratelimit-limit"), null);
});
});