A lightweight, self-hosted deploy dashboard. Git-pull, build, and reload your apps from a browser — no SSH needed. Point it at your server's apps, click Deploy, watch the logs stream live.
Built for the common small-VPS setup: a few Node/PM2 services and some static builds, all deployed from one box. One small Node process, one config file, zero external services.
⚠️ PushPilot runs shell commands on your server. It is a powerful tool and a potential backdoor if misconfigured. Read the Security section before exposing it to the internet.
- One-click deploys — each app is a named pipeline of steps (git pull → install → build → reload).
- Auto-discovery — scan your app dirs, see detected apps (git/PM2/build), click Add to make them deployable. No hand-writing config.
- Server monitoring — live CPU, RAM, disk usage per mount, load average, uptime, OS/Node info.
- Full PM2 control — start / stop / restart / reload / delete / flush / scale any process, with live per-process log tailing.
- System services — view + start/stop/restart whitelisted systemd services (nginx, mongod, …).
- Deploy history + git status — every deploy is recorded (time, result, duration, commit); each app shows its current commit and how many commits it's behind origin.
- Add / remove projects — register a new app from the UI (name, dir, git, steps, PM2) — no hand-editing config.
- MongoDB management — connection status, list databases & collections with counts/sizes, one-click
mongodumpbackups. - Nginx management — list sites, view/edit configs, enable/disable,
nginx -t, reload. - SSL / domains — list Let's Encrypt certs + expiry, renew, and issue new certs via
certbot --nginx. - Sidebar UI — a proper multi-page dashboard (Dashboard, Apps, Processes, MongoDB, Nginx, SSL, Services, Settings), responsive.
- Live log streaming — watch every command's output in real time over WebSocket.
- PM2 integration — see process status, memory, uptime, restarts; reload with a button.
- Config-driven — describe your apps in
apps.json. Nothing hardcoded. - Injection-safe — steps are
["cmd", "arg", ...]argv arrays run with no shell. - Token auth + optional IP allowlist — simple, effective, no user database.
- No build step for the UI — plain HTML/JS, served as static files.
- Tiny footprint — two dependencies (
express,ws).
Browser ──(token)──▶ PushPilot (Node/Express on your VPS)
│
├─ POST /api/apps/:id/deploy ─▶ runs the app's steps with spawn() (no shell)
├─ GET /api/pm2 ─▶ pm2 jlist (status table)
├─ POST /api/apps/:id/restart─▶ pm2 reload <name>
└─ WebSocket ─▶ streams stdout/stderr live
Each app in apps.json is a whitelist entry. The browser can only ask PushPilot to run
apps that already exist in that file, with the exact steps you defined — it can never send
an arbitrary command.
Don't want to hand-write apps.json? Point PushPilot at where your apps live and let it
find them.
- Set
SCAN_DIRSin.env(comma-separated roots, scanned one level deep):SCAN_DIRS=/var/www,/home/deploy - Click 🔍 Discover apps in the dashboard. PushPilot lists every git repo / npm
project it found, auto-detecting the branch, a matching PM2 process, and whether there's
a
buildscript — and proposes a sensible deploy pipeline for each. - Tweak the id if you like and click Add. That writes the app into
apps.jsonand it becomes deployable.
Discovery never runs anything. It only reads the filesystem and pm2 jlist to make
suggestions. A candidate becomes runnable only when you explicitly adopt it — you are the
approval gate. Adopted apps are re-validated (argv-only steps) and must live inside a
configured SCAN_DIRS root, so a tampered request can't smuggle in an arbitrary path or a
shell command. apps.json remains the single source of truth for what's allowed.
Beyond deploys, the dashboard gives you live control of the box:
- Server stats (always on): CPU, memory, disk usage per mount, load average, uptime.
- PM2 processes: every process is listed with live CPU/mem/status. Buttons to
restart, reload, stop/start, flush logs, delete, and tail live logs. By default you
can only control processes tied to a configured app; set
PM2_MANAGE_ALL=trueto manage every pm2 process on the machine. - System services: list
SERVICES=nginx,mongodin.envand the dashboard shows their status with start/stop/restart/reload buttons (viasystemctl). - Deploy history & git status: each app card shows its current commit and how far behind origin it is; the History button lists past deploys (time, result, duration, commit).
systemctl needs privileges. For the services panel to actually control units, the user running PushPilot needs permission — e.g. a polkit rule or a scoped sudoers entry like:
pushpilot ALL=(root) NOPASSWD: /bin/systemctl restart nginx, /bin/systemctl reload nginxGrant only the units + actions you need. Status reads (
is-active) usually need no sudo.
git clone https://github.com/YOU/pushpilot.git
cd pushpilot
npm install
cp .env.example .env
cp apps.example.json apps.json
# 1) Generate a strong token and put it in .env
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# → paste into PUSHPILOT_TOKEN=...
# 2) Edit apps.json to describe YOUR apps (see below)
# 3) Run it
npm start
# 🛫 PushPilot running at http://127.0.0.1:4700Open the URL, paste your token, and you're in. To keep it running:
npm install -g pm2
pm2 start ecosystem.config.js
pm2 save && pm2 startup| Var | Default | Purpose |
|---|---|---|
PUSHPILOT_TOKEN |
— (required) | The access token. Make it long and random. |
PORT |
4700 |
Port to listen on. |
HOST |
127.0.0.1 |
Bind address. Keep local if nginx proxies to it. |
ALLOWED_IPS |
(empty) | Comma-separated IP allowlist. Empty = any IP (rely on token). |
APPS_CONFIG |
./apps.json |
Path to your apps file (auto-created empty if missing). |
SCAN_DIRS |
(empty) | Comma-separated roots for auto-discovery. Empty = disabled. |
SCAN_DEPTH |
3 |
How deep discovery descends (catches nested layouts). |
PM2_MANAGE_ALL |
false |
Allow controlling ANY pm2 process, not just app-linked ones. |
SERVICES |
(empty) | Comma-separated systemd units to manage (e.g. nginx,mongod). |
MONGO_URI |
(empty) | Enables the MongoDB page. Empty = page hidden. |
BACKUP_DIR |
./backups |
Where mongodump backups are written. |
NGINX_SITES_AVAILABLE |
(empty) | Enables the Nginx page (e.g. /etc/nginx/sites-available). |
NGINX_SITES_ENABLED |
(empty) | For enable/disable via symlink (e.g. /etc/nginx/sites-enabled). |
TRUST_PROXY |
0 |
Set to 1 when behind nginx so the real client IP is used. |
The SSL page uses
certbotdirectly — no env needed; it just has to be installed. Panels for MongoDB / Nginx / SSL only appear when their prerequisites are present.certbot,systemctl, and writing to/etc/nginxgenerally require the PushPilot user to have the right privileges (root, or scoped sudoers/polkit rules).
Copy apps.example.json and describe each app. Steps are argv arrays — never shell strings.
{
"apps": [
{
"id": "api",
"name": "Backend API",
"description": "Node service under PM2",
"dir": "/var/www/myproject/backend",
"branch": "main",
"pm2": "myproject-api",
"steps": [
["git", "fetch", "origin"],
["git", "reset", "--hard", "origin/main"],
["npm", "ci", "--omit=dev"],
["pm2", "reload", "myproject-api", "--update-env"]
]
},
{
"id": "admin",
"name": "Admin Panel",
"description": "Vite static build served by nginx",
"dir": "/var/www/myproject/admin",
"branch": "main",
"pm2": null,
"steps": [
["git", "fetch", "origin"],
["git", "reset", "--hard", "origin/main"],
["npm", "ci"],
["npm", "run", "build"],
["rsync", "-a", "--delete", "dist/", "/var/www/html/admin/"]
]
}
]
}Fields: id (unique, alphanumeric), name, description, dir (working directory for
all steps), branch (display only), pm2 (process name, or null if not a PM2 app),
steps (the pipeline). git reset --hard origin/<branch> is recommended over git pull
so deploys are deterministic and never hit merge conflicts — but use whatever fits you.
Bind PushPilot to localhost and let nginx terminate TLS:
location /pushpilot/ {
proxy_pass http://127.0.0.1:4700/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # WebSocket
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}Set TRUST_PROXY=1 in .env so the IP allowlist reads the real client address.
Treat PushPilot like an admin panel with a shell attached — because that's what it is.
- Strong token. 32+ random bytes. It's the only gate. Rotate it if it leaks.
- Always HTTPS. Never send the token over plain HTTP. Put it behind nginx/Caddy TLS.
- Bind to localhost (
HOST=127.0.0.1) and proxy through nginx, rather than exposing the port. - Use
ALLOWED_IPSif you deploy from fixed IPs, or put it behind a VPN / Cloudflare Access / Tailscale. - Least privilege. Run PushPilot as a user that can only touch the app dirs it needs — not root.
- Whitelist only. Only apps in
apps.jsoncan run, only with their defined steps. Keep that file tight. - No shell. Steps run via
spawn(cmd, args, { shell: false }), so tokens are never interpreted by a shell.
PushPilot deliberately has no feature to run arbitrary commands from the browser. If you
need a new deploy step, add it to apps.json on the server.
All routes require Authorization: Bearer <token> (WebSocket uses ?token=).
| Method | Path | Description |
|---|---|---|
| GET | /api/me |
Verify a token. |
| GET | /api/apps |
List apps + deploy state. |
| GET | /api/pm2 |
PM2 process table. |
| POST | /api/pm2/:name/:action |
start/stop/restart/reload/delete/flush/scale a process. |
| GET | /api/system |
Host CPU/RAM/disk/load/uptime. |
| GET | /api/services |
Whitelisted service status. |
| POST | /api/services/:name/:action |
start/stop/restart/reload a service. |
| GET | /api/discover |
Read-only scan of SCAN_DIRS. |
| POST | /api/discover/adopt |
Persist a discovered app. |
| GET | /api/apps/:id/logs |
Recent buffered logs for an app. |
| GET | /api/apps/:id/history |
Deploy history for an app. |
| GET | /api/apps/:id/git |
Git status (commit, behind-by-N). |
| POST | /api/apps/:id/deploy |
Start a deploy. |
| POST | /api/apps/:id/restart |
pm2 reload the app's process. |
| WS | /?token=… |
Live deploy log + state stream. |
| WS | /?token=…&logs=<proc> |
Live tail of a pm2 process. |
Issues and PRs welcome. Keep the core small and dependency-light. Please don't add a feature that lets the browser run arbitrary commands — the whitelist model is the point.
MIT © PushPilot contributors
Note: "PushPilot" is a common name; if you publish to npm you may need a scoped name like
@yourname/pushpilot. The GitHub repo name is up to you.