# Deploying RijTheorie

## What this application needs

Read this part before buying hosting. It rules out a lot of it.

| Requirement | Why |
| --- | --- |
| **Node.js 20.11 or newer**, running as a long-lived process | The app renders on the server. It is not a folder of HTML files. |
| **PostgreSQL 14+** | Everything configurable — categories, questions, prices, exam rules, permissions — lives in the database. |
| Ability to set environment variables | Secrets are never baked into the build. |
| ~300 MB disk, 512 MB RAM minimum (1 GB comfortable) | |

**This will not run on PHP-only shared hosting.** If your control panel offers
only PHP version selection, FTP and a MySQL database, the application cannot run
there at all — no configuration will change that. What you need instead is any
of:

- a VPS (Hetzner, DigitalOcean, TransIP, Vimexx VPS …) — full control, cheapest
  per unit of power, you maintain it;
- shared hosting **with Node.js application support** — cPanel's "Setup Node.js
  App", Plesk's Node.js extension, or DirectAdmin equivalents;
- a container or platform host — Railway, Render, Fly.io, Coolify, Dokploy;
- Vercel, which is purpose-built for Next.js but wants to build from a Git
  repository rather than from this archive.

PostgreSQL can live on the same box or be a managed database (Neon, Supabase,
Railway). Only `DATABASE_URL` has to reach it.

---

## Building the package

On your own machine:

```bash
npm install
npm run build
npm run package
```

You get `dist/rijtheorie.tar.gz`. It contains the compiled server, the client
assets, `public/`, and the Prisma schema and migrations — no source, no
development dependencies, no `.env`.

**One thing to check before you build:** `prisma/schema.prisma` lists
`binaryTargets`. The Prisma query engine is a native binary, and the one your
laptop generates will not run on the server. The defaults cover Debian/Ubuntu
with OpenSSL 3 and Alpine. If your host is something else, run `openssl version`
and `cat /etc/os-release` there, set the matching target, and rebuild.

Getting this wrong fails at *runtime*, not at build time, with:

```
Query engine binary for current platform ... could not be found
```

---

## Deploying

### 1. Upload and unpack

```bash
scp dist/rijtheorie.tar.gz you@your-server:/var/www/
ssh you@your-server
cd /var/www && tar -xzf rijtheorie.tar.gz && cd rijtheorie
```

On cPanel or Plesk, upload the archive through the File Manager and extract it
there.

### 2. Configure

```bash
cp .env.example .env
nano .env
```

The four that are not optional:

| Variable | Value |
| --- | --- |
| `DATABASE_URL` | `postgresql://user:password@host:5432/rijtheorie` |
| `AUTH_SECRET` | 32 random bytes — see below |
| `AUTH_URL` | `https://yourdomain.nl` |
| `NEXT_PUBLIC_SITE_URL` | `https://yourdomain.nl` |

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```

Everything else is optional and the matching feature turns itself off when the
variable is empty — Stripe, email, uploads, Redis, the AI assistant. Nothing
crashes for want of a key.

`AUTH_URL` and `NEXT_PUBLIC_SITE_URL` must be the real public HTTPS URL. Sign-in
cookies and every canonical/hreflang tag are derived from them; leaving
`localhost` there breaks login in a way that looks like "the password is wrong".

### 3. Create the schema

Migrations need the Prisma CLI, which is a development dependency and is not in
the bundle. Either run this from your own machine with `DATABASE_URL` pointing
at the production database:

```bash
DATABASE_URL="postgresql://…" npx prisma migrate deploy
```

…or on the server, fetching the CLI for one command:

```bash
npx --yes prisma@6 migrate deploy
```

Then load the starting content — the 25 categories, the lessons, the questions,
the subscription plans, the roles and permissions. Run this from your machine,
where the seed script and its TypeScript runner exist:

```bash
DATABASE_URL="postgresql://…" npm run db:seed
```

It is idempotent; running it twice changes nothing.

**Then create your administrator.** Everyone who registers is a student, and the
screen that changes roles is itself behind a staff role:

```bash
DATABASE_URL="postgresql://…" node scripts/grant-role.mjs you@yourdomain.nl ADMIN
```

The seeded `admin@example.com` account and its published password exist for
local development. Delete it or change its password before the site is public.

### 4. Start it

```bash
NODE_ENV=production PORT=3000 node server.js
```

That is the whole start command. Confirm it works, then put it behind a process
manager so it survives a reboot.

**systemd** (`/etc/systemd/system/rijtheorie.service`):

```ini
[Unit]
Description=RijTheorie
After=network.target postgresql.service

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/rijtheorie
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/var/www/rijtheorie/.env
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl enable --now rijtheorie
sudo systemctl status rijtheorie
```

**PM2**, if you prefer it or your host provides it:

```bash
pm2 start server.js --name rijtheorie --env production
pm2 save && pm2 startup
```

**cPanel / Plesk Node.js app:** point the application root at the unpacked
folder, set the startup file to `server.js`, Node version 20+, mode production,
and add the environment variables in the panel's own form rather than in `.env`.

### 5. Put a web server in front

The app speaks plain HTTP on a local port. Something has to terminate TLS.

```nginx
server {
    listen 443 ssl http2;
    server_name yourdomain.nl;

    ssl_certificate     /etc/letsencrypt/live/yourdomain.nl/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.nl/privkey.pem;

    # Uploads and generous bodies for the admin editors.
    client_max_body_size 12M;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        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;
    }
}

server {
    listen 80;
    server_name yourdomain.nl;
    return 301 https://$host$request_uri;
}
```

`X-Forwarded-Proto` matters: without it the app believes it is on HTTP and
sign-in cookies marked `Secure` are dropped.

The application already sends its own security headers (HSTS, `X-Frame-Options`,
`X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`), so there is
no need to add them again in nginx — duplicated headers cause their own problems.

### 6. Check it

```bash
curl -s https://yourdomain.nl/api/health
```

```json
{"status":"ok","database":"up","integrations":{...}}
```

`"database":"down"` means `DATABASE_URL` is wrong or Postgres is unreachable —
the site will still render, with empty states everywhere, which is the most
confusing possible failure if you are not expecting it.

Then load `https://yourdomain.nl/nl`, sign in, and open `/nl/admin`.

---

## Docker, if you would rather

A `Dockerfile` is included. It builds from source rather than from the archive:

```bash
docker build -t rijtheorie .
docker run -d --name rijtheorie -p 3000:3000 --env-file .env rijtheorie
```

`docker compose up -d` brings up the app and a PostgreSQL container together.

---

## Updating a deployed site

```bash
npm run build && npm run package          # locally
scp dist/rijtheorie.tar.gz you@server:/var/www/
```

On the server:

```bash
cd /var/www
tar -xzf rijtheorie.tar.gz -C /tmp
cp rijtheorie/.env /tmp/rijtheorie/.env     # keep the configuration
rm -rf rijtheorie.old && mv rijtheorie rijtheorie.old
mv /tmp/rijtheorie rijtheorie
npx --yes prisma@6 migrate deploy           # only if migrations changed
sudo systemctl restart rijtheorie
```

Keeping `rijtheorie.old` means a rollback is one `mv` away.

---

## Things that will bite you

**No CSS, no JavaScript, unstyled page.** `.next/static` was not copied next to
the server. `npm run package` does this; copying `.next/standalone` by hand does
not.

**`Query engine binary ... could not be found`.** The `binaryTargets` in
`prisma/schema.prisma` do not match the server. See the build section.

**Login fails with correct credentials.** `AUTH_URL` does not match the address
in the browser, or the proxy is not sending `X-Forwarded-Proto`.

**`.env` in the archive.** Next copies it into the standalone output. The
packaging script deletes it and refuses to finish if it is still there — but if
you ever roll your own archive, check.

**Rate limiting resets on restart and is per-process.** It is in-memory. Behind
a load balancer, or with more than one instance, set `REDIS_URL` and move it to
a shared store.

**The legal pages are drafts.** They describe accurately what the software does.
They have not been near a lawyer, and a public Dutch site handling personal data
and payments needs one.

**The seeded accounts are public knowledge.** Their password is in the README.
Remove them before launch.
