Initiële opzet: Fastify + static, Docker, Postgres, Forgejo Actions
Some checks are pending
dev - build & deploy naar test / build-and-deploy (push) Waiting to run

This commit is contained in:
bes-r 2026-07-07 22:41:05 +02:00
commit 516a048ce7
14 changed files with 6453 additions and 0 deletions

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
.dockerignore
compose*.yaml
deploy
.forgejo
README.md
*.md

15
.env.example Normal file
View file

@ -0,0 +1,15 @@
# Kopieer naar .env voor lokaal draaien (nooit .env committen).
# Op test/prod komen deze waarden uit een .env naast de deploy-compose op de VM.
# App
PORT=3000
APP_VERSION=dev
# PostgreSQL (moet matchen met de db-service in compose)
POSTGRES_DB=teach
POSTGRES_USER=teach
POSTGRES_PASSWORD=change-me-locally
# Connectiestring die de app gebruikt.
# Hostnaam 'db' verwijst naar de postgres-service binnen het compose-netwerk.
DATABASE_URL=postgres://teach:change-me-locally@db:5432/teach

View file

@ -0,0 +1,62 @@
name: dev - build & deploy naar test
# Elke push naar dev bouwt een image en zet die op de test-VM.
on:
push:
branches: [dev]
concurrency:
group: deploy-test
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest # pas aan naar het label van jouw Forgejo-runner
env:
# git.jouwdomein.nl - host van je Forgejo (= container registry host)
REGISTRY: ${{ vars.REGISTRY }}
IMAGE_NAME: ${{ github.repository }} # bv. ramon/teach
steps:
- uses: actions/checkout@v4
- name: Image-tags bepalen
id: meta
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "sha_tag=${IMAGE}:dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "dev_tag=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
- name: Login bij Forgejo registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: |
APP_VERSION=dev-${{ github.sha }}
tags: |
${{ steps.meta.outputs.sha_tag }}
${{ steps.meta.outputs.dev_tag }}
- name: Deploy naar test-VM
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.TEST_HOST }}
username: ${{ secrets.TEST_USER }}
key: ${{ secrets.TEST_SSH_KEY }}
script: |
set -e
cd ${{ secrets.TEST_DEPLOY_PATH }}
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY }} -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# Zet de te draaien image-tag in de env van de VM
sed -i "s|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.dev_tag }}|" .env
docker compose -f compose.deploy.yaml pull
docker compose -f compose.deploy.yaml up -d
docker image prune -f

View file

@ -0,0 +1,63 @@
name: release - build & deploy naar productie
# Draait wanneer je in Forgejo een release publiceert.
# De release-tag (bv. v1.2.0) wordt de image-tag op productie.
on:
release:
types: [published]
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest # pas aan naar het label van jouw Forgejo-runner
env:
REGISTRY: ${{ vars.REGISTRY }}
IMAGE_NAME: ${{ github.repository }}
steps:
- uses: actions/checkout@v4
- name: Image-tags bepalen
id: meta
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
VERSION="${GITHUB_REF_NAME}" # de release-/tagnaam, bv. v1.2.0
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "version_tag=${IMAGE}:${VERSION}" >> "$GITHUB_OUTPUT"
echo "latest_tag=${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Login bij Forgejo registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: |
APP_VERSION=${{ steps.meta.outputs.version }}
tags: |
${{ steps.meta.outputs.version_tag }}
${{ steps.meta.outputs.latest_tag }}
- name: Deploy naar productie-VM
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
set -e
cd ${{ secrets.PROD_DEPLOY_PATH }}
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY }} -u "${{ secrets.REGISTRY_USER }}" --password-stdin
sed -i "s|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.version_tag }}|" .env
docker compose -f compose.deploy.yaml pull
docker compose -f compose.deploy.yaml up -d
docker image prune -f

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
node_modules/
npm-debug.log*
# Lokale env-bestanden met secrets nooit committen
.env
.env.local
.env.*.local
# Postgres data volume bij lokaal draaien
pgdata/
# OS / editor
.DS_Store
Thumbs.db
.vscode/
.idea/

33
Dockerfile Normal file
View file

@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
# --- Dependencies (alleen productie) ----------------------------------------
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
# npm ci als er een lockfile is, anders install (eerste keer nog geen lock)
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
# --- Runtime ----------------------------------------------------------------
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Draai als non-root gebruiker
RUN addgroup -S app && adduser -S app -G app
COPY --chown=app:app --from=deps /app/node_modules ./node_modules
COPY --chown=app:app package.json ./
COPY --chown=app:app src ./src
COPY --chown=app:app public ./public
# Versie wordt tijdens de build meegegeven door CI
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "src/server.js"]

131
README.md Normal file
View file

@ -0,0 +1,131 @@
# Teach
Digibord-webapp (Fastify + static frontend) met PostgreSQL, gecontaineriseerd en
via Forgejo Actions automatisch uitgerold: `dev` → test-VM, release → productie-VM.
## Structuur
```
teach/
├─ public/index.html # de digibord-app (voorheen teach.html)
├─ src/server.js # Fastify: serveert de app + /api + DB-pool
├─ db/001_init.sql # initieel Postgres-schema
├─ Dockerfile # productie-image (non-root, healthcheck)
├─ compose.yaml # lokaal draaien (app + postgres)
├─ deploy/compose.deploy.yaml # test/prod: pullt image uit de registry
├─ .forgejo/workflows/
│ ├─ dev.yaml # push naar dev → build + deploy test
│ └─ release.yaml # release → build + deploy prod
└─ .env.example
```
## Lokaal draaien
```bash
cp .env.example .env # pas POSTGRES_PASSWORD en DATABASE_URL aan
docker compose up --build
```
App op http://localhost:3000, healthcheck op `/healthz`, DB-check op `/readyz`.
## Architectuurkeuzes
- **Container i.p.v. losse static site**: omdat er een database bijkomt, is een
backend nodig (browser praat niet rechtstreeks met Postgres). De Fastify-app
serveert de HTML én biedt de `/api`, en praat met de DB.
- **Forgejo container registry**: CI bouwt de image één keer en pusht die; beide
VM's pullen exact dezelfde geteste image. Geen build op de productie-VM.
- **Nginx blijft ervoor** als reverse proxy / TLS op elke VM; de app luistert
alleen op `127.0.0.1:<APP_PORT>`.
## Eenmalige setup
### 1. Repo aanmaken in Forgejo
Maak een leeg repo (bv. `ramon/teach`) en push (zie onderaan).
### 2. Registry / Actions variabelen en secrets
Onder **Settings → Actions → Variables** van het repo (of org):
| Variable | Voorbeeld | Uitleg |
|------------|--------------------------|---------------------------------|
| `REGISTRY` | `git.familiebesselink.nl`| Host van je Forgejo = registry |
Onder **Settings → Actions → Secrets**:
| Secret | Uitleg |
|--------------------|--------------------------------------------------------------|
| `REGISTRY_USER` | Forgejo-gebruiker met package-write rechten |
| `REGISTRY_TOKEN` | Token/wachtwoord voor die gebruiker (scope: packages) |
| `TEST_HOST` | IP/hostname van de test-VM |
| `TEST_USER` | SSH-gebruiker op de test-VM |
| `TEST_SSH_KEY` | Private SSH-key (deploy key) voor de test-VM |
| `TEST_DEPLOY_PATH` | Pad op de test-VM met `compose.deploy.yaml` + `.env` + `db/` |
| `PROD_HOST` | IP/hostname van de productie-VM |
| `PROD_USER` | SSH-gebruiker op de productie-VM |
| `PROD_SSH_KEY` | Private SSH-key voor de productie-VM |
| `PROD_DEPLOY_PATH` | Pad op de productie-VM met de deploy-bestanden |
> De runner moet Docker + buildx beschikbaar hebben en `actions/checkout`,
> `docker/*` en `appleboy/ssh-action` kunnen ophalen (standaard van github.com;
> instelbaar via de runner-config). Pas `runs-on` in de workflows aan naar het
> label van jouw runner als dat niet `ubuntu-latest` is.
### 3. Deploy-map op elke VM
Op zowel de test- als de productie-VM, in het pad dat je bij `*_DEPLOY_PATH`
opgeeft:
```bash
mkdir -p teach && cd teach
# kopieer deze twee uit de repo:
# deploy/compose.deploy.yaml -> compose.deploy.yaml
# db/ -> db/
# maak een .env aan:
cat > .env <<'EOF'
IMAGE=git.familiebesselink.nl/ramon/teach:dev # prod: laat CI dit op :vX.Y.Z zetten
POSTGRES_DB=teach
POSTGRES_USER=teach
POSTGRES_PASSWORD=<sterk-wachtwoord>
DATABASE_URL=postgres://teach:<sterk-wachtwoord>@db:5432/teach
APP_PORT=3000
EOF
```
CI werkt bij elke deploy de `IMAGE=`-regel bij, pullt en herstart.
### 4. Nginx reverse proxy (per VM)
```nginx
server {
listen 443 ssl;
server_name digibord.familiebesselink.nl; # test-VM eigen subdomein
# ssl_certificate ... (bestaande config)
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
```
## Workflow / branching
- Werk op feature-branches, merge naar **`dev`** → automatisch naar test.
- Tevreden? Maak een **release** (tag `vX.Y.Z`) → automatisch naar productie.
## Push naar Forgejo (eerste keer)
```bash
git remote add origin https://git.familiebesselink.nl/ramon/teach.git
git push -u origin main
git push -u origin dev
```
## Volgende stap: data uit localStorage naar de database
De frontend bewaart borden/gebruikers nu nog in `localStorage`. Om echt een
gedeelde database te gebruiken, bouwen we `/api`-endpoints (boards, folders,
users) in `src/server.js` en laten we de frontend die aanroepen i.p.v.
`localStorage`. Het schema in `db/001_init.sql` is daarvoor het startpunt.

41
compose.yaml Normal file
View file

@ -0,0 +1,41 @@
# Lokaal ontwikkelen: bouwt de app zelf en start Postgres ernaast.
# cp .env.example .env (en pas het wachtwoord aan)
# docker compose up --build
# App op http://localhost:3000
services:
app:
build:
context: .
args:
APP_VERSION: dev
depends_on:
db:
condition: service_healthy
environment:
PORT: 3000
DATABASE_URL: ${DATABASE_URL}
APP_VERSION: dev
ports:
- "3000:3000"
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
# Init-scripts draaien alleen bij een lege database
- ./db:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
volumes:
pgdata:

34
db/001_init.sql Normal file
View file

@ -0,0 +1,34 @@
-- Initieel schema voor teach.
-- Dit bestand wordt bij een verse Postgres-container automatisch uitgevoerd
-- (docker-entrypoint-initdb.d). Bij bestaande databases draai je migraties apart.
--
-- Onderstaande tabellen zijn een startpunt: ze weerspiegelen wat nu in
-- localStorage zit (gebruikers en borden). Pas ze aan zodra de API-vorm vaststaat.
CREATE TABLE IF NOT EXISTS users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'teacher',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS folders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS boards (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
folder_id BIGINT REFERENCES folders(id) ON DELETE SET NULL,
owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- De volledige bordinhoud (tekeningen, widgets, blokken) als JSON.
content JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_boards_owner ON boards(owner_id);
CREATE INDEX IF NOT EXISTS idx_boards_folder ON boards(folder_id);

View file

@ -0,0 +1,45 @@
# Deploy-compose voor test/prod. Draait op de VM.
# De app-image wordt NIET gebouwd maar uit de Forgejo registry gepulld.
# CI zet het juiste IMAGE (incl. tag) in een .env naast dit bestand en draait:
# docker compose -f compose.deploy.yaml pull
# docker compose -f compose.deploy.yaml up -d
#
# Verwachte env-variabelen op de VM (in ./.env):
# IMAGE=git.jouwdomein.nl/ramon/teach:dev (of :vX.Y.Z op prod)
# POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD
# DATABASE_URL=postgres://<user>:<pw>@db:5432/<db>
# APP_PORT=3000 (poort die nginx op deze VM naar de app proxyt)
services:
app:
image: ${IMAGE}
depends_on:
db:
condition: service_healthy
environment:
PORT: 3000
DATABASE_URL: ${DATABASE_URL}
APP_VERSION: ${IMAGE}
ports:
# Alleen op localhost van de VM; nginx zit ervoor als reverse proxy
- "127.0.0.1:${APP_PORT:-3000}:3000"
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./db:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
volumes:
pgdata:

1187
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "teach",
"version": "0.1.0",
"description": "Teach digibord - Fastify server met static frontend en PostgreSQL",
"type": "module",
"main": "src/server.js",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"@fastify/static": "^8.0.1",
"fastify": "^5.1.0",
"pg": "^8.13.1"
}
}

4712
public/index.html Normal file

File diff suppressed because it is too large Load diff

81
src/server.js Normal file
View file

@ -0,0 +1,81 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import pg from 'pg';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT ?? 3000);
const HOST = process.env.HOST ?? '0.0.0.0';
const app = Fastify({
logger: true,
trustProxy: true, // achter nginx reverse proxy
});
// --- Database pool -----------------------------------------------------------
// Verbinding via DATABASE_URL, bv: postgres://teach:pw@db:5432/teach
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.PG_POOL_MAX ?? 10),
});
// Maak de pool bereikbaar in routes via app.pg
app.decorate('pg', pool);
// --- Health checks (gebruikt door Docker + load balancer) --------------------
app.get('/healthz', async () => ({ status: 'ok' }));
app.get('/readyz', async (req, reply) => {
try {
await pool.query('SELECT 1');
return { status: 'ready' };
} catch (err) {
req.log.error({ err }, 'database not ready');
reply.code(503);
return { status: 'db_unavailable' };
}
});
// --- API ---------------------------------------------------------------------
// Voorbeeld-endpoint. Hier komt straks de board/user-API die nu nog in
// localStorage zit. Prefix /api houdt het netjes gescheiden van de static app.
app.register(
async (api) => {
api.get('/version', async () => ({
name: 'teach',
version: process.env.APP_VERSION ?? 'dev',
}));
},
{ prefix: '/api' },
);
// --- Static frontend ---------------------------------------------------------
// Serveert public/index.html (de digibord-app) op /
app.register(fastifyStatic, {
root: join(__dirname, '..', 'public'),
index: ['index.html'],
});
// --- Start -------------------------------------------------------------------
const start = async () => {
try {
await app.listen({ port: PORT, host: HOST });
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
// Nette shutdown zodat Docker de container snel kan stoppen
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, async () => {
app.log.info(`${signal} ontvangen, afsluiten...`);
await app.close();
await pool.end();
process.exit(0);
});
}
start();