On This Page

Manual Deploy Guide

Manual Deployment

Deploy each NodeRouter service independently using docker compose, with full control over every configuration value. Works on servers with or without internet access.

~15 minutes Docker + Docker Compose A text editor
Want the faster path? The Quick Start guide covers a one-command setup script that does all of this automatically. Use this guide when you need full control, want to understand each file, or are deploying on a server without internet access.

Phase 1

Infrastructure

1
Prerequisites

You need Docker and Docker Compose on the target server. Nothing else — no Node.js, no Python, no cloud account.

Docker Engine

Version 24 or later. Any OS that runs Docker will work.

Docker Compose v2

The docker compose plugin — not the legacy docker-compose binary.

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
If the server has no internet access, clone on a connected machine and transfer the folder (e.g. via scp or USB). The repo contains only compose files and examples — no Docker images. See the Offline Deployment section for how to handle images.
2
Create the Docker Network

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
If the command returns network with name noderouter already exists, the network is already in place — skip this step.
3
Set Up PostgreSQL

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
Once healthy, Core and Runner reach PostgreSQL at the hostname noderouter-postgres over the Docker network. Your DATABASE_URL for the next steps will be:
postgresql://noderouter:<password>@noderouter-postgres:5432/noderouter?sslmode=disable
4
Set Up Core

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
Core is now reachable at http://<server-ip>:3000. The admin panel is at http://<server-ip>:3000/admin. Log in with the ADMIN_PASSWORD you set above.
5
Set Up nginx (optional — HTTPS)

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

Self-signed

Works offline. No public domain needed. Browser shows a security warning — safe for internal and local deployments.

bash init-self-signed.sh
Let's Encrypt

Trusted certificate, auto-renewed. Requires a public domain with a DNS A record pointing here, and port 80 open.

bash init-certs.sh

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
After adding nginx, update CORE_WS_URL in your runner's env file to use wss:// instead of ws://.
Phase 2

Runner

6
Set Up the Runner

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
Multiple runners on the same host — repeat the step with a different name and a new env file:
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
7
Verify Everything Is Running

Check that all containers are up and the Runner is connected.

1. List containers

terminal
$ 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.

Deployment complete. NodeRouter is running and ready for Mini-Apps. Go to Admin → Apps → Install to upload your first app.
Offline

Offline / Air-Gapped Deployment

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.

1
Pull and Export Images (connected machine)
# 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
2
Transfer and Load Images (offline server)
# 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
3
Start Services Without Pulling

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
Use self-signed certificates for nginx — run bash init-self-signed.sh. Let's Encrypt requires outbound internet access on port 80 for the ACME challenge.
Help

Troubleshooting

A service tried to start before the shared network was created.

Fix:

docker network create --driver bridge noderouter

Then 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-core

Fill 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.
  • Core was not yet healthy when the runner started — restart the runner after Core is up: 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-node1

  • Firewall: open port 3000 — e.g. ufw allow 3000/tcp on Ubuntu.
  • Wrong bind address: if you set CORE_BIND_ADDR=127.0.0.1 without nginx, Core is only reachable locally. Change it to 0.0.0.0 and restart.
  • Not started: run 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-core

The 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