Self-hosting

Self-hosting rollnote

rollnote is three pieces: a PostgreSQL database, the API (rollnote-api, NestJS), and the frontend (rollnote-front, Angular served by nginx). This guide gets all three running with Docker. For the full list of settings, see the configuration reference.

Requirements

  • A host with Docker and the Docker Compose plugin.
  • PostgreSQL 18 (the compose file below runs one for you).
  • Two DNS names you control, for example app.example.com (frontend) and api.example.com (API). Auth cookies are shared across them, so both should sit under the same parent domain.
  • An SMTP account for transactional email (sign-up verification, password reset, email OTP). This is required in production.

Get the code

Both repositories live on Forgejo:

git clone https://forgejo.eliottbidault.fr/RollNote/rollnote-api.git
git clone https://forgejo.eliottbidault.fr/RollNote/rollnote-front.git

Configure the environment

Create a .env file next to your compose file. The minimum for a production boot:

# PostgreSQL
POSTGRES_USER=rollnote
POSTGRES_PASSWORD=change-me
POSTGRES_DB=rollnote
DATABASE_URL=postgres://rollnote:change-me@postgres:5432/rollnote

# Better-Auth
BETTER_AUTH_SECRET=...        # >= 16 chars, openssl rand -base64 24
BETTER_AUTH_URL=https://api.example.com
ENCRYPTION_KEY=...            # base64-encoded 32 bytes, openssl rand -base64 32

# URLs
API_URL=https://api.example.com
FRONT_URL=https://app.example.com

# SMTP (required in production)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=postmaster@example.com
SMTP_PASS=change-me
EMAIL_FROM=no-reply@example.com   # a bare address, no display name

NODE_ENV=production is already set inside the API image, so it does not need to be in this file.

The API validates this on boot and exits loudly if anything required is missing or malformed (for example, an ENCRYPTION_KEY that does not decode to exactly 32 bytes). See configuration for how to generate each secret and what every variable does.

The frontend bakes its API URL in at build time, not from this .env. Set it in rollnote-front/src/environments/environment.ts before building (see the frontend’s API URL). The frontend’s Content-Security-Policy in rollnote-front/nginx-security-headers.conf also names https://api.roll-note.com in connect-src: replace it with your API URL too.

A reference compose file

This builds the API and frontend from the cloned source and wires them to Postgres. Adjust paths to match where you cloned the repos.

services:
  postgres:
    # Same image as rollnote-api's own compose.yml.
    image: postgres:18-trixie
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      # PostgreSQL 18 images keep their data in a versioned subdirectory
      # (/var/lib/postgresql/18/docker): mount the parent directory, not
      # /var/lib/postgresql/data.
      - rollnote-db:/var/lib/postgresql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
      interval: 10s
      timeout: 5s
      retries: 10

  api:
    build: ./rollnote-api
    restart: unless-stopped
    # Minimal init as PID 1: forwards SIGTERM to node and reaps zombies.
    init: true
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
    # /health checks database connectivity. start_period covers the
    # migrations and seed that run before the server starts listening.
    healthcheck:
      test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:3000/health']
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 90s
    ports:
      - '3000:3000'

  front:
    build: ./rollnote-front
    restart: unless-stopped
    depends_on:
      api:
        condition: service_healthy
    ports:
      - '8080:80'

volumes:
  rollnote-db:

Bring it up:

docker compose up --build -d

On first boot the API runs its migrations and seeds the global reference data (film stocks, formats, and the changelog) automatically, so a fresh database catches itself up with no extra step.

Put it behind HTTPS

The API and frontend listen on plain HTTP inside Docker. In production, run a reverse proxy that terminates TLS and routes each hostname to the right container. A minimal Caddy example:

api.example.com {
    reverse_proxy localhost:3000
}

app.example.com {
    reverse_proxy localhost:8080
}

Caddy provisions certificates automatically. nginx or Traefik work just as well - the only requirements are TLS and forwarding the original Host header so auth cookies and CORS line up with your FRONT_URL / API_URL.

Built-in analytics

The frontend ships a small Umami analytics snippet. On any production build (environment.production === true), rollnote-front/src/main.ts injects a script, once the page has loaded, that loads Umami from the project’s own instance under a fixed website ID:

function loadAnalytics(): void {
  const umamiScript = document.createElement('script');
  umamiScript.src = 'https://umami.eliottbidault.fr/script.js';
  umamiScript.setAttribute('data-website-id', '409a7799-df72-486c-a9ff-326b04ba30ae');
  document.head.appendChild(umamiScript);
}

if (environment.production) {
  window.addEventListener('load', loadAnalytics, { once: true });
}

On a self-hosted build this reports your visitors’ page views to that instance, not yours. There is no environment variable for it - the frontend is a static bundle, so change the source before you build:

  • To disable analytics entirely, delete the if (environment.production) block from src/main.ts.
  • To use your own Umami, replace the src host and data-website-id with the values from your instance, and swap the Umami host in the CSP of nginx-security-headers.conf (script-src and connect-src).

Become an admin

Register your account in the app as normal. Admin-only areas (such as the changelog editor) are gated on an is_admin flag on your user row. Email and username are stored encrypted, so you cannot match on them in SQL: find your user by creation date instead, then flip the flag by id:

docker compose exec postgres psql -U rollnote -d rollnote
SELECT id, created_at FROM "user" ORDER BY created_at DESC LIMIT 5;
UPDATE "user" SET is_admin = true WHERE id = '<your user id>';

Next steps