Deploy each NodeRouter service independently using docker compose,
with full control over every configuration value. Works on servers with or without
internet access.
You need Docker and Docker Compose on the target server. Nothing else — no Node.js, no Python, no cloud account.
Clone the deploy repository
All docker-compose.yml and .env.example files
live in the deploy repo. Clone it to the server first:
git clone https://github.com/noderoutercom/noderouter-deploy.git
cd noderouter-deploy
scp or USB). The repo contains only compose files and examples — no
Docker images. See the Offline Deployment section for how to handle images.
All NodeRouter services communicate over a shared Docker bridge network named
noderouter. Every docker-compose.yml file declares this
network as external: true — meaning it must exist before any service starts.
Create it once per host
docker network create --driver bridge noderouter
NodeRouter bundles a PostgreSQL container for convenience. If you already have an external PostgreSQL instance, skip this step and use its connection string directly when configuring Core and Runner.
1. Copy and edit the env file
cd postgres
cp .env.example .env
2. Fill in postgres/.env
# postgres/.env
POSTGRES_PORT=5432
POSTGRES_USER=noderouter
POSTGRES_PASSWORD=your-strong-password # REQUIRED — no default
POSTGRES_DB=noderouter
3. Start PostgreSQL
docker compose up -d
4. Wait for healthy status
# Run until it prints "healthy" (up to ~60 s on first pull)
docker inspect --format '{{.State.Health.Status}}' noderouter-postgres
noderouter-postgres over the Docker network.
Your DATABASE_URL for the next steps will be:
Core is the central API, admin panel, and WebSocket hub that runners connect to.
1. Copy and edit the env file
cd ../core
cp .env.example .env
2. Generate the required secrets
Run each command once and copy the output into the corresponding variable
in core/.env. Save RUNNER_SECRET — you will
paste the exact same value into the runner's env file later.
openssl rand -hex 32 # → JWT_SECRET
openssl rand -hex 32 # → PAIRING_KEY
openssl rand -hex 32 # → RUNNER_SECRET ← save this, runner needs the same value
3. Fill in core/.env
# core/.env
CORE_IMAGE=06042013/noderouter-core:latest
# Port exposed on the host
CORE_PORT=3000
# Use 0.0.0.0 for direct access; 127.0.0.1 when nginx sits in front
CORE_BIND_ADDR=0.0.0.0
APP_ENV=production
# Paste the DATABASE_URL from Step 3 (or your external DSN)
DATABASE_URL=postgresql://noderouter:<password>@noderouter-postgres:5432/noderouter?sslmode=disable
# Paste the generated secrets
JWT_SECRET=<64-char hex>
PAIRING_KEY=<64-char hex>
RUNNER_SECRET=<64-char hex> # copy this — runner needs the same value
ADMIN_PASSWORD=<your-initial-admin-password>
4. Start Core
docker compose up -d
http://<server-ip>:3000.
The admin panel is at http://<server-ip>:3000/admin.
Log in with the ADMIN_PASSWORD you set above.
nginx provides HTTPS termination and certificate renewal via certbot. Skip this step for plain HTTP deployments or if you are using your own reverse proxy.
1. Copy and edit the env file
cd ../nginx
cp .env.example .env
# nginx/.env
DOMAIN_NAME=core.example.com # your domain — no protocol, no slash
CERTBOT_EMAIL=admin@example.com # only needed for Let's Encrypt
CERTBOT_STAGING=0 # set to 1 to test without rate limits
2. Issue a certificate — choose one
3. Update Core to bind on localhost
Since nginx is now in front of Core, restrict Core so it doesn't expose
port 3000 publicly. Edit core/.env:
# core/.env — change this line
CORE_BIND_ADDR=127.0.0.1
# restart Core to apply the change
cd ../core && docker compose up -d
4. Start nginx
cd ../nginx && docker compose up -d
CORE_WS_URL in your runner's env file to
use wss:// instead of ws://.
The Runner executes Mini-App Python code and communicates with Core over WebSocket. It must run on the same host as Core and connect to the same PostgreSQL instance.
1. Copy and edit the env file
cd ../runner
cp .env.example .env.node1 # filename suffix matches RUNNER_NAME below
2. Fill in runner/.env.node1
# runner/.env.node1
RUNNER_IMAGE=06042013/noderouter-runner:latest
# Unique name — shown in Admin → Runners
RUNNER_NAME=node1
# WebSocket URL of Core
# Without nginx: ws://noderouter-core:3000
# With nginx: wss://core.example.com
CORE_WS_URL=ws://noderouter-core:3000
# Same PostgreSQL DSN as Core
DATABASE_URL=postgresql://noderouter:<password>@noderouter-postgres:5432/noderouter?sslmode=disable
# Must match RUNNER_SECRET in core/.env exactly
RUNNER_SECRET=<paste from core/.env>
NODE_ID= # leave blank — auto-registered on first start
ASYNC_MAX_WORKERS=4
SYNC_MAX_WORKERS=8
3. Start the Runner
RUNNER_NAME=node1 docker compose --env-file .env.node1 --project-name noderouter-runner-node1 up -d
cp .env.node1 .env.node2 # then edit RUNNER_NAME=node2 inside
RUNNER_NAME=node2 docker compose --env-file .env.node2 --project-name noderouter-runner-node2 up -d
Check that all containers are up and the Runner is connected.
1. List containers
$ docker ps --format "table {{.Names}}\t{{.Status}}"
NAMES STATUS
noderouter-postgres Up 2 minutes (healthy)
noderouter-core Up 1 minute
noderouter-nginx Up 45 seconds
noderouter-runner-node1 Up 30 seconds
2. Open the Admin Panel
# Without nginx:
http://<server-ip>:3000/admin
# With nginx:
https://<your-domain>/admin
Log in with ADMIN_PASSWORD from core/.env.
Navigate to Admin → Runners — you should see node1
with an Online status dot.
If the target server has no internet access, pull and export Docker images on a connected machine first, then transfer them. The compose files themselves are transferred as part of the cloned repo.
# Pull all required images
docker pull 06042013/noderouter-core:latest
docker pull 06042013/noderouter-runner:latest
docker pull postgres:17-alpine
docker pull nginx:stable-alpine
docker pull certbot/certbot # only if using nginx
# Export to compressed tarballs
docker save 06042013/noderouter-core:latest | gzip > noderouter-core.tar.gz
docker save 06042013/noderouter-runner:latest | gzip > noderouter-runner.tar.gz
docker save postgres:17-alpine | gzip > postgres.tar.gz
docker save nginx:stable-alpine | gzip > nginx.tar.gz
# Copy tarballs to the server (scp, USB, or shared drive)
scp *.tar.gz user@server:/tmp/
# On the offline server — load each image
docker load < /tmp/noderouter-core.tar.gz
docker load < /tmp/noderouter-runner.tar.gz
docker load < /tmp/postgres.tar.gz
docker load < /tmp/nginx.tar.gz
Pass --pull never to each docker compose up call so Docker
uses the locally loaded images and does not attempt to contact Docker Hub:
# Use this flag for every service start command
docker compose --pull never up -d
bash init-self-signed.sh.
Let's Encrypt requires outbound internet access on port 80 for the ACME challenge.
A service tried to start before the shared network was created.
Fix:
docker network create --driver bridge noderouterThen re-run docker compose up -d for the failed service.
A blank or invalid POSTGRES_PASSWORD is the most common cause.
docker logs noderouter-postgres
Fix the value in postgres/.env, remove the old container, and restart:
docker compose down -v && docker compose up -d.
A required variable in core/.env is blank. The error message names the variable.
docker logs noderouter-coreFill in the named variable, then restart from core/: docker compose up -d.
Most common causes:
RUNNER_SECRET in runner/.env.node1 does not match RUNNER_SECRET in core/.env — copy the value exactly.docker restart noderouter-runner-node1.CORE_WS_URL is wrong — should be ws://noderouter-core:3000 (or wss:// if nginx is configured).docker logs noderouter-runner-node1ufw allow 3000/tcp on Ubuntu.CORE_BIND_ADDR=127.0.0.1 without nginx, Core is only reachable locally. Change it to 0.0.0.0 and restart.docker ps and docker logs noderouter-core to check state.Edit core/.env, set a new ADMIN_PASSWORD, then restart Core — no data is lost.
# 1 — update core/.env
ADMIN_PASSWORD=new-password
# 2 — restart core
docker restart noderouter-coreThe image tarball was not loaded before starting the service.
Fix: run docker load for the missing image on the offline server:
# example — reload core image
docker load < /tmp/noderouter-core.tar.gz
# verify it is now available
docker images | grep noderouter-core