This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is api, socket, mongo, postgres, redis, and nginx.
- The app currently has two Node entrypoints, not one:
server/index.jsfor Express/API/static frontend on port8080, andserver/socket/index.jsfor Socket.IO on port9000. - The Express server serves
client/builddirectly, so the production Docker image builds the React client and copies it next to the server code. - PM2 is no longer needed in Docker. Compose runs
apiandsocketas separate services withrestart: unless-stopped. - MongoDB was previously read from
server/config/*.jsonasmongodb://127.0.0.1/letscube; containers should useMONGO_URL=mongodb://mongo:27017/letscube. - Redis was previously hardcoded to
localhost:6379in the socket process; it is now env-driven withREDIS_URL=redis://redis:6379. - Required production secrets are
AUTH_SECRET,WCA_CLIENT_ID, andWCA_CLIENT_SECRET. The client build also needsREACT_APP_WCA_CLIENT_ID. - The target Node runtime is Node
22.17.0, and the frontend is built with Vite. - No separate background workers or queues were found. Redis is used by Socket.IO for coordination.
Create .env.dev from .env.example, then run:
docker compose -f compose.yml -f compose.dev.yml --env-file .env.dev upDevelopment ports:
- Client:
http://localhost:3000 - API/static server:
http://localhost:8080 - Socket.IO:
http://localhost:9000 - MongoDB:
127.0.0.1:27017 - Redis:
127.0.0.1:6379
The dev override bind mounts the repo and keeps container node_modules in named volumes.
Create .env.prod from .env.example on the VPS and fill in real values:
cp .env.example .env.prodStart or update production:
APP_IMAGE_TAG="$(git rev-parse HEAD)" \
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --buildUsing the full commit SHA keeps manually deployed application images
immutable. APP_IMAGE_TAG=local remains available for direct development and
one-off Compose use. The deployment script always overrides APP_IMAGE_TAG
with the commit it checked out.
The nginx container listens on ports 80 and 443, proxies /socket.io/ to socket:9000, and proxies everything else to api:8080. MongoDB, PostgreSQL, and Redis are internal only.
TLS expects Let’s Encrypt files at:
/etc/letsencrypt/live/www.letscube.net/fullchain.pem
/etc/letsencrypt/live/www.letscube.net/privkey.pem
Copy existing certs from the old VPS or issue new certs before starting nginx.
View status:
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod psView logs:
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f api
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f socket
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f mongo
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f postgres
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f redis
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f nginxRestart services:
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod restart api
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod restart socketDeploy latest code from the server checkout:
APP_DIR=/opt/letscube scripts/deploy.shThe default DEPLOY_TARGET=auto chooses the smallest safe application target:
| Changed paths | Resolved target |
|---|---|
| Documentation, CI, or agent metadata only | none |
| Browser client only | api (which also serves the static client) |
| Shared socket protocol, server, dependency, container, deployment, or unknown files | all |
Override the classifier when the intended runtime impact is known:
DEPLOY_TARGET=api APP_DIR=/opt/letscube scripts/deploy.sh
DEPLOY_TARGET=socket APP_DIR=/opt/letscube scripts/deploy.sh
DEPLOY_TARGET=all APP_DIR=/opt/letscube scripts/deploy.sh
DEPLOY_TARGET=none APP_DIR=/opt/letscube scripts/deploy.shAccepted values are auto, api, socket, all, and none. Explicit
targets are honored even when the checkout is already current. With auto, an
unchanged checkout exits without replacing healthy running containers. If API,
socket, or nginx is missing, auto bootstraps both application services;
DEPLOY_FORCE=true also rebuilds and replaces both at the current commit.
Readiness waits are capped at 60 seconds before automatic rollback; override
that bound with DEPLOY_WAIT_TIMEOUT_SECONDS when diagnosing unusually slow
startup.
You can verify the conservative path classifier without fetching code or touching Docker:
scripts/test-deploy-classifier.sh
scripts/test-deploy.sh
scripts/classify-deploy-target.sh client/src/App.jsxFor any selected application target, the deploy script builds one shared image
tagged with the full commit SHA, waits for backing services without recreating
them, and applies PostgreSQL migrations before replacing application
containers. It snapshots the exact image ID of every selected running service,
then replaces and health-checks services one at a time. For all, socket rolls
out before API/static so the backward-compatible server is ready before the new
browser client is served. nginx is gracefully reloaded only after a selected
service changes, so it resolves the replacement container without dropping
unrelated connections. If nginx is not running during a bootstrap deploy, its
startup is deferred until the selected application services are ready.
If replacement readiness or the nginx reload fails, recent diagnostic logs are printed and every application service attempted in that deploy is restored in reverse order using its exact pre-deploy image and Compose configuration. nginx is then reloaded again. Rollback image tags are retained locally for manual recovery until normal image cleanup. Database migrations are not automatically reversed, so migrations must remain backward-compatible with the previous application image.
The checkout remains at the fetched commit after a failed deploy. Correct the
failure and rerun with the explicit target printed by the script; a later
auto run with no new commit otherwise treats the checkout as up to date.
Socket clients briefly reconnect only when socket is selected. Room
membership, the current scramble, waiting/competing state, and room admin are
preserved during ROOM_RECONNECT_GRACE_MS (60 seconds by default). A client
that reconnects within that window resumes the existing room without being
logically removed first. Only users with no socket on any Socket.IO instance
after the grace window are removed from the room.
An in-progress solve continues timing in the browser during that reconnect. If the solve finishes before the socket is ready, the result is retained locally, the next solve is blocked, and the saved result is submitted only after the client rejoins the room. The local copy is cleared only after the server acknowledges persistence, so a refresh or repeated reconnect does not discard the time.
Set the grace window in .env.prod if production deploys or client reconnects
need more time:
ROOM_RECONNECT_GRACE_MS=90000Keep the grace shorter than the time in which an abandoned room should appear active. Explicit leaves, kicks, and bans remain immediate and do not use the grace window.
Grand Prix is intentionally disabled in production:
GRAND_PRIX_ENABLED=falseKeep this setting disabled until the legacy scheduler is redesigned. Setting
it to true explicitly restores the old mode for development or controlled
testing.
The privacy cutover is deliberately separate from ordinary database migrations. PostgreSQL migrations run before a new application image is healthy, so using a migration to purge data could let the automatic rollback restore an image that writes the data again.
Perform the cutover in this order:
-
Deploy this release to both application services with
DEPLOY_TARGET=all. -
Confirm the API and socket health checks pass, the browser's WCA authorization request has exactly
scope=public, and a new login succeeds. -
From
/opt/letscube, record the running safe commit as the rollback floor:git rev-parse HEAD | tee .privacy-email-cutoverFuture
scripts/deploy.shruns refuse any commit that does not descend from this floor. Do not use direct Compose commands or a saved image tag to restore an older release after creating the marker. -
Run the purge from the newly deployed image:
docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod run --rm --no-deps api node server/privacy/purgeUserEmails.js
The command reports record counts only. It never prints field values and exits unsuccessfully if either database still has a value.
-
Run the same command a second time. Every matched, modified, cleared, and remaining count should be zero; this verifies that the operation is idempotent.
-
Create and verify a fresh post-cutover backup. Remove every pre-cutover local and remote backup according to the storage provider's secure deletion procedure; normal retention is not sufficient for historical private data.
If the initial deployment rolls back before step 3, do not create the marker or run the purge. Correct the release and redeploy it first. After step 3, the privacy-safe commit is the minimum supported rollback image.
The PostgreSQL app.users.email column remains temporarily as an always-empty
compatibility column. Current dual writes never send it a value and clear a
legacy value whenever they update an existing row. Removing the column is phase two:
first deploy an application release that no longer references the compatibility
column, advance every supported rollback image to that release, and only then
apply a migration that drops it. Do not combine that drop with this cutover.
The Friend System server surface is disabled by default:
SOCIAL_FEATURES_ENABLED=falseOnly set this to true during controlled development or after the #188
launch-hardening gate is complete. The same switch controls REST routes and
their realtime relay; normal room behavior is unaffected.
The API and socket processes expose dependency-aware health endpoints.
Unavailable MongoDB or Redis returns HTTP 503 with the failing check marked
error. PostgreSQL dual writes are optional, so a PostgreSQL outage returns
HTTP 200 with overall status degraded while the primary MongoDB/Redis path
remains ready.
curl -sS https://letscube.net/health/api
curl -sS https://letscube.net/health/socketThe default Socket.IO namespace also accepts a health_check event. It returns
the socket health report through the acknowledgment callback, or emits
health_status when no callback is provided. This tests a real Socket.IO
connection in addition to the HTTP readiness endpoint.
Run a MongoDB backup:
APP_DIR=/opt/letscube BACKUP_DIR=/opt/letscube/backups scripts/backup-mongo.shThe backup script creates timestamped gzip archives and keeps 7 daily, 4 weekly, and 3 monthly local backups. To upload off-server, configure BACKUP_S3_URI plus AWS or S3-compatible credentials in the environment.
Example cron:
15 3 * * * cd /opt/letscube && /opt/letscube/scripts/backup-mongo.sh >> /opt/letscube/logs/backup-mongo.log 2>&1Restore from a backup:
APP_DIR=/opt/letscube scripts/restore-mongo.sh /opt/letscube/backups/letscube-mongo-daily-YYYYmmddTHHMMSSZ.archive.gzThe restore script requires typing RESTORE and uses mongorestore --drop.
Do not upgrade the old VPS in place for this migration.
- Snapshot the existing VPS.
- Take a fresh MongoDB backup on the current server and copy it off-server.
- Create a new VPS.
- Install Docker Engine and the modern
docker composeplugin. - Clone this repo to
/opt/letscube. - Create
.env.prodwith production secrets and domains. - Copy or issue Let’s Encrypt certs for
letscube.net. - Start MongoDB, PostgreSQL, and Redis containers.
- Restore MongoDB from the production backup.
- Start the full production stack.
- Test frontend loading, login, room create/join, race/session flows, Redis-backed socket behavior, backups, and restore on non-production data.
- Confirm MongoDB, PostgreSQL, and Redis are not publicly reachable.
- Point DNS at the new VPS.
- Keep the old VPS available for rollback until the new stack has been stable.