This walks through running your own PLInt instance on a Linux server you control. It's written for anyone deploying PLInt, not just the original author - adjust paths, domains, and service names to fit your setup.
- A Linux server (Debian/Ubuntu commands are used below; adjust for your distro) with SSH access.
- Node.js 22.12 or newer (TanStack Start requires it). Check with
node -v. - PM2 for process management:
npm install -g pm2. - Git, for cloning the repo and pulling updates.
- A domain name pointed at your server, if you want it reachable from the
internet (optional - PLInt works fine on
localhosttoo).
git clone https://github.com/<your-fork-or-org>/PLInt.git
cd PLInt
npm installcp .env.example .envEdit .env:
PORT/HOST- where the app listens.127.0.0.1is a sensible default if you're putting a reverse proxy in front of it (see step 5).GITHUB_WEBHOOK_SECRET- only needed if you want auto-updates (step 6). Generate one withopenssl rand -hex 32.
npm run buildThis produces .output/server/index.mjs, a self-contained Node server.
Note on the build tool: PLInt builds with Nitro v3, which at the time of writing is a beta package (
nitro@3.0.260603-betainpackage.json). It's been reliable in testing, but if you hit a build or startup error that looks Nitro-related, two options: pin an older/newernitroversion inpackage.json, or switchvite.config.tsto the older@tanstack/nitro-v2-vite-plugin. Checknpm ls nitroand the TanStack Start hosting docs for what's current.
pm2 start ecosystem.config.cjs --only plint
pm2 save
pm2 startup # follow the printed instructions to start PM2 on bootCheck it's up:
curl http://127.0.0.1:3006
pm2 logs plintRunning Node directly on port 80/443 needs root and isn't great practice. Pick one:
# /etc/caddy/Caddyfile
your-domain.example {
reverse_proxy 127.0.0.1:3006
}
sudo systemctl reload caddyserver {
listen 443 ssl;
server_name your-domain.example;
location / {
proxy_pass http://127.0.0.1:3006;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Then get a certificate with certbot.
If your domain's DNS is on Cloudflare, a Tunnel avoids opening any inbound port on your server or router entirely - useful behind CGNAT or a restrictive firewall.
# Install cloudflared, then:
cloudflared tunnel login
cloudflared tunnel create plint
cloudflared tunnel route dns plint your-domain.exampleCreate ~/.cloudflared/config.yml:
tunnel: plint
credentials-file: /home/<you>/.cloudflared/<tunnel-id>.json
ingress:
- hostname: your-domain.example
service: http://127.0.0.1:3006
- service: http_status:404Run it as a service:
sudo cloudflared service install
sudo systemctl enable --now cloudflaredPLInt ships a tiny, dependency-free webhook listener
(scripts/deploy/webhook-server.js) that redeploys automatically on every
push to your deploy branch.
Start it:
pm2 start ecosystem.config.cjs --only plint-webhook
pm2 saveIt listens on 127.0.0.1:9000/webhook by default (see WEBHOOK_PORT in
.env). Expose that path publicly through your reverse proxy - for
example, with Caddy:
your-domain.example {
reverse_proxy /webhook 127.0.0.1:9000
reverse_proxy 127.0.0.1:3006
}
Or route it through the same Cloudflare Tunnel by adding another ingress
entry pointing /webhook at http://127.0.0.1:9000.
Then, in your GitHub repo: Settings → Webhooks → Add webhook
- Payload URL:
https://your-domain.example/webhook - Content type:
application/json - Secret: the same value as
GITHUB_WEBHOOK_SECRETin.env - Which events: "Just the push event"
Push to your deploy branch (main by default - change with DEPLOY_BRANCH
in .env) and check logs/deploy.log to confirm it ran.
Every server-executed language works out of the box in the sense that it
tells you exactly what's missing if its interpreter isn't installed - the
app never needs a restart to pick up a newly-installed one. See
scripts/install-interpreters.sh for an apt-based installer, split into a
light default tier and a heavier opt-in tier:
bash scripts/install-interpreters.sh --list # see what's covered
bash scripts/install-interpreters.sh # install the light tier
bash scripts/install-interpreters.sh --tier2 # add the heavier tierIf you're not using the webhook, or just want to force an update:
bash scripts/deploy/update.shpm2 logs plintshows a port-in-use error - something else is already bound toPORT. Change it in.env, then reload by referencing the ecosystem file so PM2 actually re-reads it:pm2 reload ecosystem.config.cjs --only plint --update-env. (Plainpm2 restart plintreuses PM2's cached env and won't pick up the change.)- The webhook returns a Cloudflare 502 - this means nothing is
answering on the port Cloudflare Tunnel is routing to, not that your
app rejected the request. The most common cause: you changed
WEBHOOK_PORT(in.envorecosystem.config.cjs) or the tunnel's routed port, butplint-webhookis still running with its old, cached port from whenever it was last started.git pullalone doesn't restart it, PM2 caches env vars at start time. Fix:pm2 reload ecosystem.config.cjs --only plint-webhook --update-env, then confirm it's actually listening:ss -tlnp | grep <port>. Pushes that touchecosystem.config.cjsorscripts/deploy/webhook-server.jsnow trigger this automatically (seescripts/deploy/update.sh), but a change you deploy manually or via commands run directly on the server still needs the manual reload above. - A language always says "not found" - its interpreter isn't
installed, or isn't on the
PATHthat PM2's process sees. Confirm withwhich <the-binary>as the same user PM2 runs as. - The webhook returns 401 - the secret in
.envdoesn't match what you entered in GitHub's webhook settings. Also confirmGITHUB_WEBHOOK_SECRETactually reaches the process:pm2 env plint-webhook | grep GITHUB_WEBHOOK_SECRET(empty means the reload above didn't pick up.env, e.g. it's not next toecosystem.config.cjs). - Build fails after a
git pull- checknpm ls nitroagainst the version pinned inpackage.json; see the Nitro note in step 3.