A self-hosted employee onboarding platform built with Next.js 14. HR and admin staff define task-based workflows, assign them to new hires, track completion, and manage approvals from a single web interface.
Developed with Claude Code.
- Workflow builder -- compose reusable tasks into named workflows and assign them to users
- Three task types -- Standard (checkbox), Upload (file submission), and Learning (course + scored quiz)
- Approval queue -- supervisors and HR review and approve/reject uploaded submissions
- Learning courses -- rich-text course content with multiple-choice quizzes and printable pass certificates
- Resources library -- upload files or add web links that users can access from their dashboard
- Document categories -- admin-defined categories for organising resource documents
- Role-based access control -- five-role hierarchy with per-route and per-resource enforcement
- Email notifications -- SMTP or Microsoft Graph (Entra ID) for overdue task reminders and workflow events
- Branding -- customisable organisation name, logo, and primary/accent colours
- Structured audit logs -- JSON logs to stdout and database, viewable by admins with configurable retention
- Rate limiting -- Redis-backed per-user and per-IP limits on logins, uploads, approvals, and other sensitive actions
Roles are cumulative -- each role inherits the permissions of all roles below it.
| Role | Key permissions |
|---|---|
USER |
Complete own tasks, view own resources and documents |
SUPERVISOR |
View and approve tasks for users in supervised workflows |
PAYROLL |
All supervisor permissions + upload documents, view all documents |
HR |
All payroll permissions + manage tasks, workflows, courses, and assign users |
ADMIN |
Full access including users, branding, categories, email config, logs, and factory reset |
- Framework -- Next.js 14 (App Router, standalone output)
- Auth -- Auth.js v5 (next-auth 5.0.0-beta.25), Credentials provider, JWT sessions, Argon2id
- Database -- PostgreSQL 16 via Prisma 5
- Cache / Rate limiting -- Redis 7 (authenticated, required in production)
- Styling -- Tailwind CSS
- Rich text -- TipTap v2
- Container -- Docker + Docker Compose
Local development runs the app directly on your machine with Node.js. There is no TLS, no reverse proxy, and no Docker required. The app runs on http://localhost:3000 with hot-reload enabled so code changes appear immediately in the browser.
This mode is for developing and testing the application. It is not suitable for production use.
- Node.js 20+
- PostgreSQL 16 running locally (or via
docker run -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:16-alpine) - Redis 7 running locally (or via
docker run -p 6379:6379 redis:7-alpine). Optional -- the app falls back to in-memory rate limiting ifREDIS_URLis not set, but this is single-process only.
npm install --legacy-peer-deps--legacy-peer-deps is required because TipTap v2 and next-auth v5 beta have peer dependency conflicts that do not affect runtime behaviour.
cp .env.example .envEdit .env with development values:
DATABASE_URL="postgresql://postgres:dev@localhost:5432/onboarding"
POSTGRES_USER=postgres
POSTGRES_PASSWORD=dev
POSTGRES_DB=onboarding
AUTH_SECRET=dev-secret-at-least-32-characters-long
NEXTAUTH_URL=http://localhost:3000
UPLOAD_DIR=./uploads
NODE_ENV=development
REDIS_PASSWORD=
ADMIN_BOOTSTRAP_PASSWORD=localdev123REDIS_PASSWORD can be left empty for local development. The app will use in-memory rate limiters. EMAIL_ENCRYPTION_KEY and CRON_SECRET are only needed if you want to test email or cron features locally.
npx prisma migrate deploy
npx prisma db seedThis creates all tables and seeds the admin user with the password from ADMIN_BOOTSTRAP_PASSWORD.
npm run devOpen http://localhost:3000 and log in with username admin and the password you set in ADMIN_BOOTSTRAP_PASSWORD.
npm testThis section covers deploying the application in a production or staging environment using Docker Compose. The app container runs on the App VLAN (internal network) and listens on port 3000. It must be fronted by a TLS-terminating reverse proxy running on the DMZ, which forwards traffic to the app's routable IP across the VLAN boundary.
Internet
|
[Firewall]
| |
DMZ App VLAN (Internal)
| |
[Reverse Proxy container] [OnboardingApp container]
Caddy / Nginx <app-ip>:3000
| |
+--- TCP/3000 (allowed) ----->+
|
[backend Docker network (internal)]
| |
[PostgreSQL] [Redis]
The reverse proxy container on the DMZ is the sole ingress point for the application. The firewall permits only the DMZ to reach the app on TCP/3000 -- all other inbound traffic to the app's IP is denied. The app container attaches to a macvlan/ipvlan network for its routable App VLAN IP and to the backend Docker network for database and Redis access. The backend network is marked internal: true, so PostgreSQL and Redis are unreachable from outside Docker.
Replace <app-ip> throughout this section with the IP address assigned to the app container on the App VLAN.
- A Linux host (or VM) with Docker and Docker Compose v2 installed
- A TLS certificate for the external hostname
- A reverse proxy container on the DMZ (Caddy recommended; Nginx, Traefik, and HAProxy also work) -- see REVERSE_PROXY.md for complete configurations
- Inter-VLAN routing between the DMZ and App VLAN via the firewall, with a rule allowing TCP/3000 from the proxy to the app
- A macvlan or ipvlan Docker network providing the app container a routable IP on the App VLAN
- Network access from the host to an SMTP server or Microsoft Graph API (if email notifications are needed)
git clone <repository-url>
cd OnboardingAppGenerate all required secrets. Each secret should be unique and random.
# Database password
openssl rand -hex 20
# Auth.js session signing secret
openssl rand -base64 32
# Redis password
openssl rand -base64 32
# Initial admin password
openssl rand -base64 16
# Email encryption key (only if configuring email)
node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))"
# Cron secret (only if scheduling cron jobs)
openssl rand -base64 32Save these values -- you will need them in the next step.
cp .env.example .env
chmod 600 .envEdit .env and replace every placeholder. A complete production .env looks like this:
# Database -- the password in DATABASE_URL must match POSTGRES_PASSWORD
DATABASE_URL="postgresql://onboarding:<db-password>@db:5432/onboarding"
POSTGRES_USER=onboarding
POSTGRES_PASSWORD=<db-password>
POSTGRES_DB=onboarding
# Auth.js
AUTH_SECRET=<auth-secret>
# The external URL users will access -- must be HTTPS, no trailing slash.
# Auth.js uses this for CSRF validation, callback URLs, and cookie settings.
NEXTAUTH_URL=https://onboarding.corp.example.com
# Upload directory inside the container (mapped to a Docker volume)
UPLOAD_DIR=/app/uploads
# Must be 'production' for secure cookie flags and other hardening
NODE_ENV=production
# Redis -- Docker Compose constructs REDIS_URL from this automatically
REDIS_PASSWORD=<redis-password>
# Initial admin password -- required on first boot
ADMIN_BOOTSTRAP_PASSWORD=<admin-password>
# Reverse proxy is in place -- enable per-IP rate limiting
TRUST_PROXY=true
# Disable the factory reset endpoint in production
DISABLE_FACTORY_RESET=true
# Email encryption key -- required if configuring email in the admin panel
# 64-character hex string (32 bytes)
EMAIL_ENCRYPTION_KEY=<64-hex-chars>
# Cron secret -- required if scheduling overdue-task or log-cleanup cron jobs
CRON_SECRET=<cron-secret>
# Log level: ERROR (errors only), ACCESS (errors + login events), LOG (everything)
LOG_LEVEL=ACCESS
# Log retention in days (default 90)
LOG_RETENTION_DAYS=90Important notes:
POSTGRES_PASSWORDand the password inDATABASE_URLmust be identical.NEXTAUTH_URLmust match the external hostname exactly -- CSRF protection depends on it.TRUST_PROXY=truemust only be set when a reverse proxy is confirmed to strip client-suppliedX-Forwarded-Forheaders. See REVERSE_PROXY.md.
docker compose up -d --buildOn the first start, the entrypoint script will:
- Run
prisma migrate deployto create all database tables. - Run the seed script to create the admin user (using
ADMIN_BOOTSTRAP_PASSWORD) and built-in document categories. - Start the Next.js server on port 3000.
Watch the logs to confirm a clean startup:
docker compose logs -f appYou should see:
Running database migrations...
Seeding database (no-op if already seeded)...
Admin password set from ADMIN_BOOTSTRAP_PASSWORD
Seeded admin user: admin
Seeded default branding
Seeded built-in document categories
Starting application...
Set up your reverse proxy on the DMZ to terminate TLS and forward traffic to <app-ip>:3000, where <app-ip> is the app container's routable IP on the App VLAN. Ready-to-use configurations for both Caddy and Nginx are provided in REVERSE_PROXY.md.
The proxy must:
- Terminate TLS with TLSv1.2+ and strong ciphers
- Set
X-Forwarded-Forto the real client IP (strip client-supplied values) - Set
Hostto match theNEXTAUTH_URLhostname - Generate and forward
X-Request-IDfor log correlation - Block
/api/cron/*,/api/admin/factory-reset, and/uploads/
# Health check
curl -s https://onboarding.corp.example.com/api/health
# Expected: {"status":"ok"}
# Login page loads
curl -sI https://onboarding.corp.example.com/login
# Expected: 200 with security headers (CSP, HSTS, X-Frame-Options)
# Cron blocked externally
curl -sI https://onboarding.corp.example.com/api/cron/log-cleanup
# Expected: 403Log in with username admin and the password from ADMIN_BOOTSTRAP_PASSWORD. Change the admin password immediately.
In the admin panel under Admin > Email, configure either:
- SMTP -- any standard SMTP server (port 465 or 587 with TLS). Port 25 is not supported.
- Microsoft Graph (Entra ID) -- requires an Entra ID app registration with the
Mail.Sendapplication permission. Enter the tenant ID, client ID, client secret, and sending address.
EMAIL_ENCRYPTION_KEY must be set in .env before configuring either provider -- it encrypts stored credentials at rest.
Use the Send Test Email button to verify the configuration.
Two endpoints should be called by an internal scheduler on the App VLAN. These must be called directly to the app's IP (not through the reverse proxy, which blocks /api/cron/*). Replace <app-ip> below with the app container's actual IP on the App VLAN.
Overdue task reminders -- call daily:
curl -X POST http://<app-ip>:3000/api/cron/overdue-tasks \
-H "X-Cron-Secret: <your-cron-secret>"Log retention cleanup -- call weekly:
curl -X POST http://<app-ip>:3000/api/cron/log-cleanup \
-H "X-Cron-Secret: <your-cron-secret>"Example crontab on the Docker host:
# Daily at 2 AM -- overdue task reminders
0 2 * * * curl -sf -X POST http://<app-ip>:3000/api/cron/overdue-tasks -H "X-Cron-Secret: <secret>" > /dev/null
# Sunday at 3 AM -- log retention cleanup
0 3 * * 0 curl -sf -X POST http://<app-ip>:3000/api/cron/log-cleanup -H "X-Cron-Secret: <secret>" > /dev/nullNote: with macvlan networking, the Docker host cannot reach its own container's macvlan IP by default. You may need a macvlan shim interface on the host, or run cron from another host on the App VLAN that can route to the container IP.
# Stop all services
docker compose down
# Pull latest code and rebuild
git pull
docker compose up -d --buildData is persisted in two named Docker volumes (pgdata for the database, uploads for files) and survives container restarts and rebuilds.
Application logs are written to stdout in JSON format:
# Follow live logs
docker compose logs -f app
# Search for login failures
docker compose logs app | grep login_failureAdmin users can also view logs in the web UI under Admin > Logs with level and date filters.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
POSTGRES_USER |
Yes | PostgreSQL username (Docker Compose) |
POSTGRES_PASSWORD |
Yes | PostgreSQL password (Docker Compose) -- must match DATABASE_URL |
POSTGRES_DB |
Yes | PostgreSQL database name (Docker Compose) |
AUTH_SECRET |
Yes | Random secret for Auth.js session signing -- min 32 bytes |
NEXTAUTH_URL |
Yes | Public base URL, e.g. https://onboarding.example.com |
UPLOAD_DIR |
Yes | Filesystem path for uploaded files (/app/uploads in Docker) |
NODE_ENV |
Yes | production for deployed environments |
REDIS_PASSWORD |
Yes | Password for Redis; Docker Compose constructs REDIS_URL from it |
ADMIN_BOOTSTRAP_PASSWORD |
Yes | Initial admin password on first boot |
TRUST_PROXY |
Production | Set to true when behind a reverse proxy that strips X-Forwarded-For |
DISABLE_FACTORY_RESET |
Production | Set to true to disable the factory reset endpoint |
EMAIL_ENCRYPTION_KEY |
If using email | 64-character hex string for encrypting stored email credentials |
CRON_SECRET |
If using cron | Shared secret for cron endpoints -- min 32 bytes |
LOG_LEVEL |
No | Minimum log level: ERROR, ACCESS, or LOG (default: LOG) |
LOG_RETENTION_DAYS |
No | Days to retain AppLog rows before cron cleanup (default: 90) |
- Passwords hashed with Argon2id (
m=65536, t=3, p=4) - JWT sessions with 2-hour absolute maximum and 30-minute idle timeout
- Session cookies:
HttpOnly,SameSite=Strict,__Secure-prefix in production - CSP with per-request nonces and
strict-dynamic; violations reported to/api/csp-report - HSTS with 2-year max-age, includeSubDomains, and preload
- Uploaded files validated by magic bytes (not extension) and capped at 25 MB
- All database queries use parameterised statements via Prisma
- Rate limiting is Redis-backed with per-user and per-IP keys
- Login rate limiting keys on username when no reverse proxy is configured, preventing global lockout DoS
- Cron secret compared with
crypto.timingSafeEqualto prevent timing attacks - Users are never deleted, only deactivated, to preserve audit trails
- Docker network isolation: database and Redis on an internal bridge network with no external routing; app exposed only on the App VLAN with firewall restricting inbound to the DMZ proxy on TCP/3000
- API responses include
Cache-Control: no-storeto prevent proxy/browser caching of authenticated data - All 429 responses include
Retry-Afterheaders per RFC 6585 X-Request-IDpropagated from reverse proxy through the app for end-to-end request tracing