- Deploy kopieert de productie-.env van de host in de build-context: Next.js bakt NEXT_PUBLIC_* in en valideert DATABASE_URL/HOTEL_NAME (SKIP_ENV_VALIDATION is verboden voor productie, zie src/env.ts). - Dockerfile builder installeert git (next.config.ts deploymentId). - Deploy-container krijgt --env-file + dezelfde volumes als compose, stopt ook de oude compose-container (poort 3002) en rolt terug via compose bij een falende health check.
EpicNext-CMS v1.0.1
A modern, high-performance content management system for Habbo hotel emulators, built on Next.js 16 (App Router) with Drizzle ORM and React 19. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id hashing with legacy md5/bcrypt auto-upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
System Requirements
| Component | Version | Notes |
|---|---|---|
| Node.js | 26.8.1 | Current release pinned in .nvmrc |
| pnpm | >= 11.25.0 | Recommended package manager |
| npm | >= 11.x | Supported alternative |
| yarn | >= 4.x | Supported alternative |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Docker | 24+ | Optional — for containerized deployment |
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE |
Quick Start
1. Clone & Install
Choose your preferred package manager:
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
pnpm (recommended):
pnpm install
npm:
npm install
yarn:
yarn install
2. Database Setup
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
The CMS reads emulator-owned tables (users, items, rooms, bans, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in src/db/schema.ts is generated from the existing database structure and does not modify it.
Note: The CMS does not own the emulator schema — it maps to those tables via Drizzle. Never run
drizzle-kit push/migrateagainst the shared DB. CMS-owned tables (website_*,radio_*, etc.) are created via idempotent SQL indrizzle/migrations/.
3. Configure Environment
cp .env.example .env
Edit .env with at minimum:
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
AUTH_SECRET=<random 32+ character string>
HOTEL_NAME=YourHotel
APP_URL=http://localhost:3002
See .env.example for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
4. Run CMS Migrations
# pnpm
pnpm db:migrate
# npm
npm run db:migrate
# yarn
yarn db:migrate
Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log, etc.) via idempotent SQL files in drizzle/migrations/. Emulator tables are never touched.
5. Build & Start
# Development (hot reload)
pnpm dev # or: npm run dev / yarn dev
# Production
pnpm build && pnpm start # or: npm run build && npm start
Open http://localhost:3002 in your browser.
6. First Login
- Register an account at
/register, or log in with an existing emulator account. - Grant yourself admin access:
UPDATE users SET rank = 7 WHERE username = 'yourname'; - Visit
/adminand configure your hotel via Admin → CMS Settings.
Docker Deployment
The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. Only the CMS runs in Docker; the Nitro/Octane client and its renderer stay outside and are served by nginx on the host.
Prerequisites
- Docker 24+ and Docker Compose v2
.envfile configured (see step 3 above)- A MySQL/MariaDB database reachable from the container (
DATABASE_URLhost should point to the DB server, not127.0.0.1unless it's reachable from inside the container) - (Optional) A shared
Gamedatadirectory at/var/www/Gamedatawith write access (see Volumes below)
Quick Start with Docker
# 1. Clone and configure
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
cp .env.example .env
# Edit .env with your database credentials and settings.
# The container runs on the host network, so all 127.0.0.1 references
# (DATABASE_URL, REDIS_URL, RCON, imaging) keep pointing at the host as-is.
# 2. Stop any existing host-side CMS that occupies port 3002 (if present).
pm2 stop next 2>/dev/null || true
# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
# container user can write imports/uploads to them.
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
# 4. Build and start
docker compose up -d --build
# 5. Run migrations. The slim runtime image has no source/tsx, so run migrations
# on the HOST (against the same database) before/after starting the container.
pnpm db:migrate # or: npm run db:migrate / yarn db:migrate
# 6. Check logs
docker compose logs -f cms
The CMS will be available at http://localhost:3002.
Reverse proxy: This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (
/client/,/nitro-client/) and/gamedata/directly from/var/www/Octane/distand/var/www/Gamedata, and proxies/to the CMS on127.0.0.1:3002. PointAPP_URL/NEXT_PUBLIC_APP_URLat the public site URL.
Automatic Updates
Updating is fully scripted — no need to touch Docker or nginx by hand. The
script scripts/docker-update.sh pulls the latest main, runs CMS migrations
on the host, rebuilds the image, recreates the container and waits for a healthy
status. It also makes sure a stale host-side PM2 CMS (pm2 stop next) stays
stopped so it can't clash on port 3002.
Automatically (recommended): a nightly cron job is already configured on a
production server that installed this setup. It runs the script every night at
03:30 and appends to logs/docker-update.cron.log:
crontab -e
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1
Manually — to update right now (same steps as the cron runs):
cd /var/www/atom-nexst
./scripts/docker-update.sh
# log: logs/docker-update.log
The script aborts safely (exit 1) if the working tree has uncommitted changes so
a git pull can never clobber local edits, and leaves the container running if
health fails so you can debug it (exit 3). Failed runs are reported in the log;
an exit of 0 means the CMS is healthy on the new commit.
Docker Commands
| Command | Description |
|---|---|
docker compose up -d --build |
Build and start in background |
docker compose down |
Stop and remove containers |
docker compose logs -f cms |
Follow CMS logs |
docker compose exec cms sh |
Open a shell in the CMS container |
docker compose restart cms |
Restart the CMS container |
docker compose pull && docker compose up -d --build |
Update and redeploy |
pnpm db:migrate (on the host) |
Run database migrations (slim image has no source) |
./scripts/docker-update.sh |
Full automated update (manual or cron) |
How Package Manager Detection Works
The Dockerfile checks for lockfiles in this order:
pnpm-lock.yaml→ uses pnpm (fastest, recommended)yarn.lock→ uses yarnpackage-lock.json→ uses npm- No lockfile → falls back to
npm install
This means you can use any package manager on your host machine — the Docker build will automatically match.
Override the detection explicitly with
docker compose build --build-arg PACKAGE_MANAGER=pnpm(ornpm/yarn).
Volumes
Only the CMS runs in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
| Container Path | Host Path | Mode | Purpose |
|---|---|---|---|
/app/public/nitro-assets |
./public/nitro-assets |
rw | Imported furni/pet/effect assets |
/app/public/swf |
./public/swf |
rw | SWF costumes / icons (imports) |
/app/storage |
./storage |
rw | Uploaded media (persistent) |
/var/www/Gamedata |
/var/www/Gamedata |
rw | Shared gamedata root (hardcoded path) |
About the hardcoded /var/www/Gamedata path: src/lib/services/furni-asset-dirs.ts defines DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host /var/www/Gamedata at the identical path inside the container so existsSync('/var/www/Gamedata') succeeds and nginx keeps serving /gamedata/ from the same directory.
The Nitro/Octane client and renderer are NOT mounted — nginx on the host serves them directly:
| Component | Host path | How it's served |
|---|---|---|
| Nitro/Octane client | /var/www/Octane/dist |
nginx location ^~ /client/ and /nitro-client/ alias |
| Octane-Renderer | /var/www/Octane-Renderer |
Optional. Run separately (or as the commented-out imager service) proxied by nginx /imaging |
| Camera uploads | /var/www/Camera |
nginx /camera/ alias |
Container user & write permissions: the CMS container runs as www-data (UID/GID 33) by default to match the host owner of /var/www/Gamedata. Ensure the other write volumes (./public/nitro-assets, ./public/swf, ./storage) are also owned by www-data on the host:
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
If your host user UID differs, override the run user at build time (defaults to www-data, which already exists in the base image with UID/GID 33):
docker compose build --build-arg RUN_USER=1000
Networking
The CMS container runs with network_mode: host. This lets the existing 127.0.0.1 references in .env keep working against host services — MariaDB (3306), DragonflyDB/Redis (6379), the emulator RCON/API (3003/3001) and the avatar imager — without re-writing any environment variables. The container consequently listens directly on the host's port 3002, so stop any previous host-side CMS (e.g. pm2 stop next) that occupies that port before starting it.
The build also runs on the host network (build.network: host) because this host disables Docker iptables (/etc/docker/daemon.json: "iptables": false), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
Health Check
The container includes a health check that hits /api/health on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with:
docker inspect --format='{{.State.Health.Status}}' epicnext-cms
ORM Setup & Type Generation
Drizzle ORM (Primary Data Layer)
Drizzle ORM is the runtime data layer. The connection is a singleton in src/lib/db.ts:
import { db } from "@/lib/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm/expressions";
const found = await db.select()
.from(users)
.where(eq(users.username, "hello"));
Drizzle CLI (drizzle-kit) is used for local development tasks — it is a devDependency and is never bundled in production.
| Command | What it does |
|---|---|
pnpm db:generate |
Draft SQL from Drizzle schema into drizzle/drafts/ (review + copy) |
pnpm db:studio |
Open Drizzle Studio (dev only) |
pnpm db:introspect |
Reverse-engineer an existing DB into a Drizzle schema draft |
pnpm db:schema:generate |
Regen committed src/db/schema.ts from previous schema + live DB |
The CMS does not use
drizzle-kit pushordrizzle-kit migrate— the database is shared with the emulator. Apply CMS DDL only viapnpm db:migrate.
DragonflyDB (Caching, Rate Limiting, SSE)
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via REDIS_URL using the ioredis client. It is optional: without it the CMS falls back to in-process memory.
Install (Ubuntu/Debian)
curl -fsSL -o /tmp/dragonfly_amd64.deb \
https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
apt-get install -y /tmp/dragonfly_amd64.deb
systemctl enable --now dragonfly
Configure
Edit /etc/dragonfly/dragonfly.conf:
--bind=127.0.0.1
--port=6379
--maxmemory=2gb
--version_check=false
Point the CMS at it
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
Verify via /api/health — it should report "redis": true.
Nginx Configuration
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration.
Prerequisites
- SSL certificates in
/etc/ssl/cert.pemand/etc/ssl/key.pem(or use Let's Encrypt) - CMS running on
127.0.0.1:3002
Reference Configuration
proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name yourdomain.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 20m;
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;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache html_cache;
proxy_cache_valid 200 60s;
proxy_ignore_headers Vary;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache-Status $upstream_cache_status always;
}
location /api/ {
proxy_pass http://127.0.0.1:3002;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location /_next/static/ {
proxy_pass http://127.0.0.1:3002;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /imaging {
proxy_pass http://127.0.0.1:3030;
add_header Cache-Control "public, max-age=3600, s-maxage=86400";
}
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
}
Production Deployment (PM2)
pnpm build
pm2 start pnpm --name "next" -- start
pm2 save
Restart after updates:
git pull
pnpm install
pnpm build
pm2 restart next
Scripts
| Command | Description |
|---|---|
pnpm dev |
Start development server (hot reload) |
pnpm build |
Production build |
pnpm start |
Start production server |
pnpm typecheck |
Run TypeScript type checking |
pnpm test |
Run all tests (Vitest) |
pnpm db:migrate |
Apply pending SQL migrations |
pnpm db:migrate:status |
Show migration status |
pnpm db:schema:generate |
Regen src/db/schema.ts from prior schema + live DB |
pnpm db:generate |
Draft SQL via drizzle-kit → drizzle/drafts/ |
pnpm db:studio |
Drizzle Studio (dev) |
pnpm db:introspect |
drizzle-kit introspect (draft) |
pnpm analyze |
Build + open bundle analyzer |
pnpm jobs:worker |
Start background task worker |
pnpm biome:check |
Lint and format code |
Replace
pnpmwithnpm runoryarnfor other package managers.
Performance Features
| Feature | Description |
|---|---|
| React Compiler | Automatic memoization — reduces unnecessary re-renders |
| View Transitions API | Native browser transitions between page navigations |
| Lenis Smooth Scroll | Fluid, customizable scrolling |
| Server-Sent Events | Real-time radio now-playing & listeners via SSE |
| DragonflyDB Caching | Caches API responses up to 30s in DragonflyDB |
| RCON (TCP Socket) | Live commands to the emulator (credits, badges, kick, ban) |
| Streaming & Suspense | Next.js App Router streaming for fast page loads |
| Automatic Image Optimization | next/image with Sharp for resizing and WebP/AVIF |
| Compression | Gzip compression enabled on all responses |
| PWA | Service worker with skip-waiting, stale CSS chunk recovery |
| Biome | Fast linting and formatting (replaces ESLint + Prettier) |
Architecture
├── drizzle/
│ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations)
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
├── scripts/
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
│ ├── jobs-worker.ts # Background task scheduler
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
├── src/
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
│ │ └── relations.ts # Drizzle relations
│ ├── app/ # Next.js App Router (pages & API routes)
│ ├── actions/ # Server Actions
│ ├── components/ # UI components
│ ├── lib/
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
│ │ ├── db.ts # Drizzle connection singleton (runtime)
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
│ │ └── motion.ts # Framer Motion animation variants
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
│ └── env.ts # Zod-validated environment schema
├── public/
│ ├── assets/ # Images, icons, fonts
│ ├── nitro-assets/ # Nitro client assets (~3GB, volume-mounted in Docker)
│ └── swf/ # SWF client files (volume-mounted in Docker)
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile # Multi-stage, multi-package-manager Docker build
├── next.config.ts # Next.js configuration
└── .env.example # Environment template with all options
Optional Integrations
Emulator — RCON
For live actions (credits, badges, rank changes, kick, ban, hotel alerts). The emulator must be running with RCON enabled at RCON_HOST:RCON_PORT.
Client — Nitro
The /client page loads the Nitro client. Configure the client URL via Admin → CMS Settings (nitro_client_url). See update-Nitrov3.sh for deployment.
Radio
Supports any streaming radio (e.g., Azuracast). Configure endpoints via CMS Settings:
radio_now_playing_api_urlradio_listeners_api_url
The radio player uses SSE for real-time updates (10s interval, no polling).
Background Jobs
Run as a persistent process:
pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
AI Content Moderation
Optional OpenAI-powered moderation for comments and guestbook posts. Set OPENAI_API_KEY.
FlareSolverr (Cloudflare Bypass)
Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.
docker compose up -d flaresolverr
FLARESOLVERR_URL=http://localhost:8191
Furniture Import with Auto-Translation
The CMS automatically translates furniture names and descriptions to 13 languages on every import:
- Native languages (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian
- Additional languages (via LibreTranslate Docker at
127.0.0.1:5000): Portuguese, Finnish, Polish, Russian, Arabic, Japanese
Theming
12 preset themes with fully customizable colors via Admin → Theme (/admin/theme).
Development
pnpm dev # Start with hot reload
pnpm typecheck # Type check all files
pnpm test # Run test suite
pnpm analyze # Build and analyze bundle sizes
pnpm biome:check # Lint and format
Contributing
- Ensure typecheck and tests pass:
pnpm typecheck && pnpm test - Follow existing code conventions (Server Components where possible)
- SQL migrations in
drizzle/migrations/must be idempotent - For new database code, use the Drizzle runtime directly (
import { db } from "@/lib/db") - Avoid
any— usebiome-ignorecomments only when unavoidable
License
CC BY-NC-SA 4.0. See LICENSE for details.
Test aanpassing voor Gitea Actions