42 KiB
EpicNext-CMS v1.0.3
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.9.0 | 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 |
| Valkey | 8.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, Valkey, 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. The CMS runs in Docker; the Nitro/Octane client stays outside and is served by nginx on the host. The server-side avatar imager also runs in its own Docker container (avatar-imaging-pixinode, port 8082) — see Avatar Imaging.
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 mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
# 4. Build, migrate and start a release tied to the Git commit.
# Requires Linux, Bash, Git, flock and Docker Compose v2; no host Node/pnpm.
bash scripts/docker-update.sh
# 5. Follow logs
docker compose logs -f cms
The CMS listens on port 3002 using Linux host networking. Existing database,
Redis, emulator and shared gamedata services must already be configured.
Create the mounted directories before installation; do not mount the checkout,
.next or node_modules over /app inside the production container.
Updating a Docker clone
From your clone, run:
# Also verifies that your public domain serves the expected commit:
CMS_PUBLIC_URL=https://your-hotel.example bash scripts/docker-update.sh
The script follows the current branch's configured Git upstream. It refuses local uncommitted/untracked work, pulls fast-forward only and restarts itself if the updater changed. It does not reset or delete local work. Configure the upstream to your fork/branch if that is where you receive updates.
It builds a commit-tagged image and runs migrations in the matching builder
container, using the clone's .env; no host dependency installation is needed.
Only after build and migrations succeed does it recreate the CMS service. It
compares the running image ID, image revision and /api/health release with the
expected Git commit. With CMS_PUBLIC_URL, it also checks the domain through
your proxy/CDN. A healthy response from another release is an error.
git pull alone updates source files, not an existing container. docker compose restart restarts the same image. docker compose pull downloads registry images;
it does not fetch changes to this Git-built CMS. Use the updater for releases.
A clone does not install an automatic scheduler. If desired, explicitly add a
cron entry with the absolute path of your checkout; keep logs in its logs
directory. Do not configure both CI and Compose updates for the same instance.
The updater refuses an active epicnext-cms-app CI-managed container.
Logs: logs/docker-update.log. After cutover, a startup, image or HTTP check
failure automatically recreates the CMS with its previous image and verifies its
local HTTP release and database health. The command still returns nonzero: a
rollback is not a successful update. Recovery uses the current Compose configuration
and .env; it restores application code, not configuration edits or database migrations.
The previous image must carry a valid release label. First installations have no
previous release: a failed candidate remains available for diagnosis. A failed
rollback is explicitly logged with a retained epicnext-cms:rollback-... recovery tag.
Public routing must be checked separately after rollback. SIGKILL or host power loss
cannot run the rollback handler.
After success, the updater keeps the two most recent distinct releases tracked in
logs/docker-release-history.log. Cleanup only removes older recorded CMS image
tags, without force; images referenced by other containers (including stopped ones)
are preserved and retried on later updates. Untracked historical images, build cache,
other services and persistent volumes are not pruned. Retain the history file between
updates. Before production migrations, retain your normal database backup.
Portable images on the Gitea registry
Docker builds no longer load your installation's .env. The builder uses disposable
fixture values; the runtime reads HOTEL_NAME, APP_URL, AUTH_SECRET, database and
asset settings when the container starts. Missing/invalid required runtime values
stop startup with the setting names, without printing credentials.
Configure IMAGER_URL with the actual avatar renderer endpoint and BADGE_URL with
the badge directory URL (or a local path). The legacy NEXT_PUBLIC_IMAGER_URL and
NEXT_PUBLIC_BADGE_URL names remain supported at runtime. If the imager points back
to the CMS /imaging proxy, set IMAGING_UPSTREAM_URL to the actual renderer to avoid
a loop. With no imager configured, the CMS uses Habbo's public renderer. Client
requests use the existing /api/imaging/avatar proxy. Admin badges preserve their
local /swf/c_images/album1584 fallback. Public URL resolution uses runtime APP_URL.
After changing .env, recreate the container; an image rebuild is not required.
NEXT_PUBLIC_CMS_RELEASE deliberately remains compiled into the image.
Guided installation from this repository
On a Linux host with Docker Engine, the Compose plugin, Git and flock available:
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms
bash cms install
The wizard asks for your public URL, delivery method, hotel name, existing database
connection, Redis and avatar imager. It generates an authentication secret and
creates .env with private permissions. An existing .env is preserved; review
its APP_URL, AUTH_URL, RCON and optional original Laravel APP_KEY when moving
an existing hotel. This installs the CMS, not the Habbo database, emulator, Redis
or reverse proxy. Point HTTPS at port 3002 before running the final public check.
The wizard may request sudo to prepare persistent directories for UID/GID 33;
it does not recursively change ownership of existing assets.
Choose prebuilt to download the images specified by this repository in
docker-image.txt, without entering a registry namespace or commit tag. Choose
source when building your own fork on the installation host. Private packages
still require docker login on that host. Maintainers must update docker-image.txt
if the publication registry or namespace changes.
After installation, update with:
bash cms update
The updater pulls the configured Git upstream, loads .docker-install, uses the
matching application/migration images and verifies the running release locally
and at the saved public URL. Existing application rollback remains available on a failed cutover;
database migrations are not reversed.
bash cms install --configure-only saves configuration without preparing runtime
directories or starting containers. Neither .env nor .docker-install is
committed or sent in Docker build contexts. Existing users of
bash scripts/docker-update.sh retain the previous behavior when no wizard
profile exists; explicit CMS_IMAGE_REPOSITORY/CMS_PUBLIC_URL overrides still work.
Container publication is disabled. CI builds, checks and deploys the CMS, but it does not log in to a registry or upload container images.
Diagnose an update that is not visible
git rev-parse HEAD
docker inspect --format '{{.Image}}' epicnext-cms
docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' epicnext-cms
curl -fsS http://127.0.0.1:3002/api/health
curl -fsS https://your-hotel.example/api/health
Both HTTP responses must report the expected release. unknown means the image
was built without a commit ID. If the local endpoint is current but the public
one is old, check nginx's upstream, other CMS processes and proxy/CDN caching.
Health responses must not be cached. The release is compiled into Next.js and
cannot be changed simply by injecting a new variable into an old container.
Docker commands
| Command | Purpose |
|---|---|
bash scripts/docker-update.sh |
Pull, build, migrate, recreate and verify |
docker compose logs -f cms |
View CMS logs |
docker compose exec cms sh |
Open a shell in the running CMS |
docker compose restart cms |
Restart the existing release |
docker compose down |
Stop services; does not update code |
pnpm db:up / pnpm db:down |
Manage the optional MariaDB service |
For a manual build, export CMS_RELEASE=$(git rev-parse HEAD) before
docker compose build cms; deployment and migration verification remain your
responsibility. Docker uses the committed pnpm lockfile and pinned Node version.
The build cache can stay enabled: copying changed source invalidates the
application build layer. Deleting all Docker cache is not an update mechanism.
Volumes
Only the CMS (and the optional avatar imager container, see Avatar Imaging) run 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 |
| Avatar imager | /docker/Polaris-imager |
Docker container avatar-imaging-pixinode on port 8082 — see Avatar 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 mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
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), Valkey/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
MariaDB Turbo (bulk loads)
docker-compose.yml ships an opt-in mariadb-turbo service — a dedicated MariaDB 11 container tuned for loading >50 MB JSON dumps (furnidata, external texts, etc.). It configures itself via mysqld flags on the command:, so no external .cnf file has to be mounted or kept in sync:
pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo)
Key settings (see the command: block in docker-compose.yml):
max_allowed_packet = 512M— 50 MB JSON batches no longer hit the 16 MB packet ceiling.innodb_flush_log_at_trx_commit = 2+innodb_doublewrite = 0— durability relaxed for bulk writes.innodb_buffer_pool_size = 2G,innodb_log_file_size = 1G,bulk_insert_buffer_size = 512M.net_read_timeout/net_write_timeout = 600,wait_timeout = 3600— fixes thedrizzle-kit"Pulling schema from database..." hang by never starving introspection/DDL sessions behind a long import.performance_schema = OFF— saves ~1-2 GB RAM.
The datadir lives in a named volume (mariadb-turbo-data), never a host bind-mount: shared-filesystem sync trashes InnoDB files the same way it corrupts pnpm's node_modules. Named volumes stay inside the container filesystem, so 50 MB of JSON writes never cross a host-sync boundary.
Port note: the service uses
network_mode: hostand binds127.0.0.1:3306— stop the host MariaDB first, otherwise the port collides with the standalone.envDATABASE_URL(localhost:3306).
node_modules & host-sync corruption
pnpm's store is hard-linked and its .bin shims are symlinks, so node_modules must never be a host bind-mount (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes node_modules into the image at build time; it is never bind-mounted. For local dev, mount a named volume (node_modules:/app/node_modules) instead of the host directory, and keep node_modules / the pnpm store out of any shared-filesystem bind.
Loading a 50 MB JSON dump
# Habbo furnidata (auto-detects roomitemtypes / wallitemtypes / effecttypes)
pnpm db:bulk --file=/var/www/Gamedata/config/FurnitureData.json
# Flat array of documents → generic JSON store
pnpm db:bulk --file=/data/products.json --table=docs --category=furni
# Key/value texts
pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default
The importer (scripts/bulk-import-json.ts) maps rows onto src/db/schema-gamedata.ts, batches them into 2000-row multi-row INSERTs (one statement per chunk), uses ON DUPLICATE KEY UPDATE so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See "ORM Setup & Type Generation" → Bulk JSON storage for the design.
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:bulk |
Batch-import >50 MB JSON (2000-row chunks, resumable) via scripts/bulk-import-json.ts |
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.
Bulk JSON storage (MariaDB)
Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's JSON type is an alias for LONGTEXT and can never be indexed directly, so src/db/schema-gamedata.ts follows the "promote + index" pattern:
- The raw JSON document stays intact in a
longtext/mediumtextcolumn (payload,value). - The fields you actually
WHERE/ORDER BYon are promoted into real columns (sprite_id,class_name,kind,title,text_key) and indexed. - Optional VIRTUAL generated columns extract indexed fields from the payload with
JSON_EXTRACT(MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer.
Three tables are exported:
| Table | Purpose | Unique key |
|---|---|---|
gamedata_furnidata |
One row per furni/clothing item (habbo furnidata_json shape) | (source, sprite_id) |
gamedata_docs |
Generic JSON document store (per-key documents) | (category, doc_key) |
gamedata_texts |
External-texts style key/value pairs | (category, text_key) |
The bulk importer (scripts/bulk-import-json.ts, run via pnpm db:bulk) is DB-driven and fast precisely because each chunk is a single multi-row INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags:
| Flag | Default | Description |
|---|---|---|
--file=<path> |
— (required) | JSON file (habbo furnidata_json or flat array) |
--table=<name> |
furnidata |
One of furnidata | docs | texts |
--source=<x> |
habbo |
source value for furnidata |
--category=<x> |
default |
category value for docs / texts |
--chunk-size=N |
2000 |
Rows per multi-row INSERT |
--limit=N |
0 |
Stop after N rows (dry-test) |
--truncate |
off | DELETE rows for this source/category first |
The script sets FOREIGN_KEY_CHECKS=0 for the session and is safe to interrupt: each chunk commits on its own, and ON DUPLICATE KEY UPDATE makes re-runs overwrite instead of appending.
Valkey (Caching, Rate Limiting, SSE)
Valkey is a drop-in, Redis-compatible open-source in-memory datastore (successor to Redis OSS). The CMS connects to it via REDIS_URL using the ioredis client. It is optional: without it the CMS falls back to in-memory memory.
Install (Ubuntu/Debian)
# Option 1: Package repository (recommended)
curl -fsSL https://packages.valkey.io/valkey-pgp-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/valkey-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/valkey-archive-keyring.gpg] https://packages.valkey.io/apt $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/valkey.list
apt-get update && apt-get install -y valkey
# Option 2: Direct .deb download
curl -fsSL -o /tmp/valkey_amd64.deb \
https://github.com/valkey-io/valkey/releases/download/8.1.1/valkey_8.1.1-1_amd64.deb
apt-get install -y /tmp/valkey_amd64.deb
systemctl enable --now valkey
Configure
Edit /etc/valkey/valkey.conf:
bind 127.0.0.1
port 6379
maxmemory 2gb
maxmemory-policy allkeys-lru
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:8082/;
proxy_set_header Connection "";
proxy_http_version 1.1;
add_header Cache-Control "public, max-age=300";
}
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
}
/imagingtrailing-slash gotcha:location /imaging(no trailing slash) combined withproxy_pass http://…8082/;rewrites/imaging/avatarimageinto//avatarimageand 404s. Always uselocation /imaging/+ a trailing-slashproxy_passso/imaging/avatarimagereaches the imager's/avatarimageroute.
Avatar Imaging
Avatar images (profile figures, chat badges, forum avatars, admin previews) are rendered server-side by Polaris-imager (duckietm/Polaris-imager) — a headless Nitro/Octane renderer that runs @pixi/node (real WebGL via gl + Xvfb) inside a Docker container named avatar-imaging-pixinode. It serves GET /avatarimage?figure=… on port 8082 and can emit PNG/APNG/GIF, gestures, actions, scenes and more.
Request flow
browser → Cloudflare → (page rule /origin for /imaging/*) → host:8082
browser → nginx origin → location /imaging/ → 127.0.0.1:8082/avatarimage
URLs look like https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&img_format=png&size=l&direction=4. The public NEXT_PUBLIC_IMAGER_URL points at that endpoint (see src/lib/imager.ts). Responses carry Cache-Control: public, max-age=300, so browsers and Cloudflare reuse a render for 5 minutes.
Requirements (host)
- Docker (daemon with
build.network: host, same as the CMS build — this host disables Docker iptables). - An nginx that serves
/gamedata/(FigureData/FigureMap/EffectMap…) and/gamedata/bundled(.nitroassets) over HTTP so the renderer can fetch them. The compose file points athost.docker.internal:8081.
Configuration — /docker/Polaris-imager/.env
NITRO_GAMEDATA_URL=http://host.docker.internal:8081/gamedata/config
NITRO_ASSET_URL=http://host.docker.internal:8081/gamedata/bundled
AVATAR_IMAGING_FPS=12
AVATAR_IMAGING_MAX_FRAMES=60
AVATAR_IMAGING_HOST=0.0.0.0
AVATAR_IMAGING_PORT=8082
AVATAR_IMAGING_RATELIMIT_MAX=600
AVATAR_IMAGING_SCENE=1
AVATAR_IMAGING_WARDROBE=1
AVATAR_IMAGING_HABBO_FONTS=1
AVATAR_IMAGING_CHAT_BUBBLES=1
AVATAR_IMAGING_HOTEL_URL=https://<hotel>
AVATAR_IMAGING_HOTEL_NAME=<hotel>
Build & start
The image is self-contained — the Dockerfile clones the pinned Nitro/Octane renderer and runs the full yarn/vite bundle at build time, then produces a slim Debian runtime with Xvfb + Mesa software rendering:
cd /docker/Polaris-imager
docker compose build
docker compose up -d # container: avatar-imaging-pixinode
docker logs -f avatar-imaging-pixinode # expect: "listening on http://0.0.0.0:8082"
The container runs restart: unless-stopped and ships a healthcheck that probes /health (reports { "ready": true } once the headless renderer has booted). Verify a render:
curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l'
A production issue that this section documents: the avatar-imaging-pixinode image can be removed by a docker image prune, which used to take the whole site's avatars offline (502 on /imaging/*). Keep the image tagged and re-build it via the commands above if it ever disappears (docker compose build && docker compose up -d).
Automatic upstream fallback
Since then the CMS-side avatar proxies (/api/imaging/avatar and /imaging) are resilient: when the configured upstream (the Polaris container on 8082) fails or times out, the request is retried against Habbo's public renderer (https://www.habbo.com/habbo-imaging/avatarimage), with the effect parameter stripped and img_format forced to png, since the public renderer supports neither. The response is then marked with X-Imager-Source: primary|fallback and a shorter Cache-Control TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar <img> on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
Figure string validation (src/app/api/imaging/avatar/route.ts)
The CMS-side route re-validates the incoming figure against /^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i. Real Habbo figure strings use type-id-color parts separated by dots (hd-180-1.ch-210-66), so each part allows an optional second number. Invalid input is rejected with 400 before any render happens.
Performance Tuning (production)
The CMS shares its MySQL/MariaDB database with the emulator, so the hot wins come from caching, asset serving and database knob-tuning — not from a storage-engine migration.
1. gzip_static pre-compression of gamedata JSON
The client downloads FurnitureData.json (~48 MB, one file per supported language) plus other large JSON from /gamedata/. nginx has gzip and gzip_static on (see /etc/nginx/nginx.conf), but with no pre-built .gz file it re-compressed the 48 MB on every request (~0.25–0.30 s of CPU per miss).
scripts/compress-gamedata.mjs pre-compresses every JSON >1 MB in <Gamedata>/config and <Gamedata>/bundled/config once (gzip level 9): FurnitureData*.json go from ~48 MB to ~2.4 MB (~95 % smaller). It is idempotent — a file is only re-compressed when the source mtime is newer than its .gz (e.g. after a Studio catalog export).
pnpm gamedata:compress # manual run
Once the .gz exists, nginx gzip_static serves it straight from disk with Content-Encoding: gzip and effectively zero CPU. Measured result on the production origin: 0.25–0.30 s → ~0.005 s per file.
The production host runs the script on a cron so newly exported catalogs are compressed shortly after they land:
crontab -e
# */15 * * * * node /var/www/atom-nexst/scripts/compress-gamedata.mjs >> /var/www/atom-nexst/logs/gamedata-compress.log 2>&1
2. MariaDB tuning
The whole habbo datadir is roughly 0.5 GB; the default InnoDB buffer pool (128 MB) pushed index/row reads to disk. The pool is raised to 4 GB and the slow-query log is enabled (threshold 2 s), both live (SET GLOBAL …) and persisted for boot in /etc/mysql/mariadb.conf.d/zz-cms-performance.cnf:
[mysqld]
innodb_buffer_pool_size = 4G
slow_query_log = 1
long_query_time = 2
slow_query_log_file = /var/log/mysql/mariadb-slow.log
The slow-query log surfaces hot-spot SQL for a follow-up index/query audit. The CMS connection pool itself is already tuned (pool size 25, connection_limit in DATABASE_URL).
For bulk-written tables (game-data JSON, imports) a containerized mariadb-turbo variant is available — see MariaDB Turbo.
3. Asset caching headers
| Path | Cache-Control |
|---|---|
/swf/**, /nitro-assets/** |
public, max-age=604800, stale-while-revalidate=2592000 |
/gamedata/**, /client/** |
public + expires 7–30 d (nginx) |
/imaging/* |
public, max-age=300 |
/camera/** |
public, max-age=31536000, immutable |
/_next/static/** |
public, max-age=31536000, immutable |
4. Cache layers (Redis + CDN)
The CMS caches through cached() / cachedQuery() (src/lib/cache.ts): an in-memory fast path with a Redis (Valkey-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with redis-cli DBSIZE. With Cloudflare in front, static and /imaging/* responses are additionally cached edge-side (cf-cache-status), cutting origin load further.
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 db:bulk |
Batch-import >50 MB JSON via scripts/bulk-import-json.ts |
pnpm db:up / pnpm db:down |
Start / stop the mariadb-turbo container |
pnpm gamedata:compress |
Pre-compress large gamedata JSON to .gz (gzip_static) |
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 |
| Valkey Caching | Caches API responses up to 30s in Valkey |
| 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 |
| gzip_static (gamedata) | 48 MB FurnitureData JSON served from pre-built .gz — no per-request CPU |
| 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)
│ ├── bulk-import-json.ts # 50 MB+ JSON importer (2000-row chunks, UPSERT)
│ ├── jobs-worker.ts # Background task scheduler
│ ├── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
│ └── compress-gamedata.mjs # Pre-compress large gamedata JSON (gzip_static)
├── src/
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
│ │ ├── schema-gamedata.ts # Large-JSON storage schema (furnidata/docs/texts)
│ │ └── 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 → Valkey)
│ │ └── 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.
Byparr (Cloudflare Bypass)
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
docker compose up -d byparr
BYPARR_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.