Standalone Node
Build
git clone <your fork> 3x && cd 3x
npm ci
npm run pack:staged -w @3x-engine/sdk # builds the SDK + the npm tarball served at /v1/sdk/
npm run build -w @3x/api # → apps/api/dist/server.mjs (+ dist/migrations, dist/sdk)
npm run build -w @3x/dashboard # → apps/dashboard/.nextRun the API
export X3_ENV=production
export X3_DATABASE_URL=postgres://x3:…@db.internal:5432/x3
export X3_PUBLIC_API_URL=https://api.3x.example.com
export X3_DASHBOARD_URL=https://app.3x.example.com
export X3_SECRET=… X3_ENCRYPTION_KEY=… CRON_SECRET=… X3_DASHBOARD_KEY=…
export X3_SCHEDULER=1 # in-process scheduler, one tick per minute
export X3_TRUSTED_IP_HEADERS=x-real-ip # the header YOUR proxy sets – see below
export PORT=4310
npm run migrate -w @3x/api # or X3_AUTO_MIGRATE=1
node apps/api/dist/server.mjsWith X3_ENV=production the server refuses to start while required settings are missing or
malformed and names them (never their values). Any Postgres ≥ 15 works. Run exactly one process with X3_SCHEDULER=1 per deployment if you
want predictable load – several are safe (jobs are claimed with leases), they just compete.
A systemd unit:
[Service]
EnvironmentFile=/etc/3x/api.env
WorkingDirectory=/opt/3x
ExecStart=/usr/bin/node apps/api/dist/server.mjs
Restart=always
User=x3Run the dashboard
X3_API_URL=http://127.0.0.1:4310 X3_DASHBOARD_KEY=… npm run start -w @3x/dashboard # port 4311Client IPs behind a reverse proxy
3x needs the visitor's IP for bot budgets and the daily session hash (it never
stores it). Without X3_TRUSTED_IP_HEADERS the socket address is used – correct when Node faces
the internet directly, wrong behind a proxy (every visitor would share the proxy's IP).
| Setup | X3_TRUSTED_IP_HEADERS |
|---|---|
| Node directly on the internet | unset |
nginx with proxy_set_header X-Real-IP $remote_addr; | x-real-ip |
Caddy (header_up X-Real-IP {remote_host}) | x-real-ip |
| Cloudflare in front (proxied DNS) | cf-connecting-ip |
| Vercel | unset (auto x-real-ip,x-forwarded-for) |
Never use x-forwarded-for outside Vercel unless your proxy overwrites it: clients can send their
own first entry. Country comes from X3_COUNTRY_HEADERS (default x-vercel-ip-country,cf-ipcountry);
behind nginx with GeoIP set a header such as x-country and list it there.
nginx example:
server {
server_name api.3x.example.com;
location / {
proxy_pass http://127.0.0.1:4310;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_buffering off; # MCP streams responses
}
}Health
GET /v1/health → {ok, db, version, time}; use it for load-balancer checks. The dashboard
reaches the API over the internal URL in X3_API_URL; it forwards the visitor's IP only together with
X3_DASHBOARD_KEY.