This guide walks you from zero to a live Mini-App in three phases: deploy NodeRouter on your server, build your first tool, then publish it to your team.
Quick Deploy — Run one command on your server to set up NodeRouter automatically:
Or follow the manual steps below for full control over configuration.
You need two things installed on the server where NodeRouter will run. Nothing else is required — no Node.js, no Python, no cloud account.
Run the one-line command below on your server. The script downloads all compose files, walks you through a short set of prompts, generates every secret automatically, and starts all containers in the correct order.
bash <(curl -fsSL https://raw.githubusercontent.com/noderoutercom/noderouter-deploy/main/scripts/setup.sh)
Answer the prompts — one by one:
| Prompt | What to do |
|---|---|
| Service selection | |
Configure nginx HTTPS proxy? [y] |
Type n — skip nginx for now |
Configure PostgreSQL? [y] |
Press Enter — yes, include it |
Configure Core? [y] |
Press Enter — yes, include it |
Configure Runner? [y] |
Press Enter — yes, include it |
| Secrets | |
Customize any secret? [n] |
Press Enter — auto-generate all (JWT, runner secret, admin password, DB password) |
| PostgreSQL | |
Deploy bundled PostgreSQL? [y] |
Press Enter — use the built-in container |
POSTGRES_PORT [5432] |
Press Enter — keep default port |
POSTGRES_USER [noderouter] |
Press Enter — keep default username |
POSTGRES_DB [noderouter] |
Press Enter — keep default database name |
| Core | |
CORE_IMAGE [noderouter-core:latest] |
Press Enter — use the official image |
DATABASE_URL [postgresql://…] |
Press Enter — auto-filled from PostgreSQL step |
CORE_PORT [3000] |
Press Enter — expose on port 3000 |
| Runner | |
Runner name [node1] |
Press Enter — or type a custom name |
RUNNER_IMAGE [noderouter-runner:latest] |
Press Enter — use the official image |
RUNNER_SECRET [auto-filled] |
Press Enter — pre-filled from Core |
ASYNC_MAX_WORKERS [4] |
Press Enter — keep default |
SYNC_MAX_WORKERS [8] |
Press Enter — keep default |
Navigate to
http://<your-server-ip>:3000/admin in
your browser. Log in with the
ADMIN_PASSWORD printed in the summary box
at the end of setup.
Admin panel → http://<your-server-ip>:3000/admin
Password → the ADMIN_PASSWORD from the summary box at the end of setup
A Mini-App is a plain folder with six files. Create the
folder
app_hello_world/ on your local machine and
add each file below.
app_hello_world/
├── manifest.json # app identity, version, data space
├── main.py # business logic — def execute(data):
├── index.html # UI template
├── index.js # component logic & data binding
├── style.css # component-scoped styles
└── libs/ # local dependencies (optional)
manifest.json
The manifest tells NodeRouter how to register and run your
app. The db_schema field gives your app its
own isolated database space — no other app can read or
write to it.
{
"name": "app_hello_world",
"version": "1.0.0",
"display_name": "Hello World",
"description": "My first Mini-App",
"execution": "sync",
"db_schema": "schema_app_hello_world"
}
"sync" (instant responses, < 5 seconds) or
"async" (background jobs for heavy
calculations or batch reports).
index.html — The UI Template
This file defines what your users see. It is a standard
<template> element — NodeRouter injects
it directly into the page without an iframe. Use any
Bootstrap utility class here; Bootstrap is already loaded
by the platform shell.
<!-- index.html -->
<template id="app-hello-world-tpl">
<div class="p-4">
<h4 class="fw-bold mb-1">Hello World</h4>
<p class="text-muted mb-4">My first NodeRouter Mini-App.</p>
<div class="d-flex gap-2 mb-4">
<input
id="inp-name"
class="form-control"
style="max-width:240px"
placeholder="Enter your name"
/>
<button id="btn-run" class="btn btn-primary">Run</button>
</div>
<div id="result" class="p-3 bg-light rounded text-muted">
Result will appear here…
</div>
</div>
</template>
index.js — Component Logic
This file registers your app as a
W3C Custom Element. All queries use this.querySelector() —
scoped strictly inside your component so nothing conflicts
with other Mini-Apps running in the same shell.
// index.js
class AppHelloWorld extends HTMLElement {
connectedCallback() {
// Clone the template and mount it inside this component
const tpl = document.getElementById('app-hello-world-tpl');
this.appendChild(tpl.content.cloneNode(true));
// Bind the Run button — always scope with this.querySelector()
this.querySelector('#btn-run')
.addEventListener('click', () => this.run());
}
async run() {
const name = this.querySelector('#inp-name').value || 'World';
const resultEl = this.querySelector('#result');
resultEl.textContent = 'Loading…';
// Call the platform sync API — NodeRouter routes to main.py
const res = await fetch('/api/sync/app_hello_world', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const data = await res.json();
resultEl.textContent = data.message;
}
}
customElements.define('app-hello-world', AppHelloWorld);
POST /api/sync/app_hello_world — NodeRouter
forwards the request body to
main.py → execute(data) and returns the
result in the same HTTP response.
main.py — Business Logic
Every Mini-App backend implements one function:
execute(data). NodeRouter calls it with the
JSON body from the frontend and returns whatever you
return from this function. Keep it focused — pure logic,
no web framework needed.
# main.py
# The platform calls execute(data) for every sync request.
# 'data' is the JSON body sent from index.js.
def execute(data: dict) -> dict:
name = data.get("name", "World")
# Your business logic lives here.
# You can query your database, run calculations,
# call external APIs, read files — anything Python can do.
return {
"message": f"Hello, {name}! Your Mini-App is live.",
"status": "ok"
}
execute()
is serialised as JSON and sent back to your frontend
component automatically.
style.css — Component StylesOptional but recommended. Scope every rule under your component's tag name to avoid any conflict with other Mini-Apps or the platform shell.
/* style.css */
/* Prefix every rule with your component tag to stay scoped */
app-hello-world #result {
font-family: monospace;
font-size: 0.9rem;
min-height: 56px;
}
app-hello-world .btn-primary {
min-width: 80px;
}
Zip the contents of the folder — not the folder itself. The zip root must contain the files directly, with no extra wrapping directory.
# Mac / Linux — zip the contents of the folder
cd app_hello_world
zip -r ../app_hello_world.zip .
# Windows PowerShell
Compress-Archive -Path app_hello_world\* -DestinationPath app_hello_world.zip
app_hello_world.zip should show
manifest.json, main.py, etc. at
the top level, not inside another folder.
Open the NodeRouter Admin Panel, go to
Mini-Apps → Install, and drop your
app_hello_world.zip file into the upload
area.
✓ Received app_hello_world.zip
✓ Validated manifest.json
✓ Unpacked → /shared-file-system/plugins/app_hello_world/
✓ Database schema_app_hello_world created
✓ Runner hot-reloaded app_hello_world into RAM (0.3s)
✓ Live — available to all users now
Your app now appears in the platform menu for every user.
Click on
Hello World, type a name, hit
Run — the response comes back from
main.py in real time.
You've built a sync Mini-App. Here are the natural next steps as your tools grow in complexity.
The setup script exits with Docker daemon is not running.
Fix:
sudo systemctl start docker, then re-run the setup script.Core fails to start with bind: address already in use.
Fix: Edit core/.env and change CORE_PORT to a free port (e.g. 3001), then restart core:
docker compose -f core/docker-compose.yml --project-name noderouter-core up -dAccess the admin panel on the new port: http://<server-ip>:3001/admin.
Browser times out on http://<server-ip>:3000/admin even though the container is running.
Common causes:
ufw allow 3000/tcp on Ubuntu).http://localhost:3000/admin, not the external IP.docker logs noderouter-core.The runner container is running but shows as offline or disconnected in Admin → Runners.
Fix:
RUNNER_SECRET in runner/.env.node1 exactly matches RUNNER_SECRET in core/.env.docker logs noderouter-runner.docker restart noderouter-runner.You can reset it by editing core/.env and restarting the container — no data is lost.
# 1 — set a new password in core/.env
ADMIN_PASSWORD=your-new-password
# 2 — restart core to pick it up
docker restart noderouter-coreThe most common cause is a wrong zip structure — the files are inside a sub-folder inside the zip instead of at the root.
Fix: zip the contents of the folder, not the folder itself:
# correct — files are at the zip root
cd app_hello_world
zip -r ../app_hello_world.zip .Opening the zip should show manifest.json at the top level, not inside another folder.
The script is safe to re-run. Any service whose .env file already exists is skipped automatically — only missing services are configured again.
To reconfigure a specific service, delete its .env file and re-run:
# example: reconfigure core only
rm core/.env
bash scripts/setup.shUsually a missing or blank required environment variable.
Fix: check the container logs to see the exact error, then correct the value in the relevant .env file and restart:
# view the last 50 log lines for core
docker logs --tail 50 noderouter-core
# restart after fixing the .env
docker restart noderouter-core