Run a Node.js app on a Cube
Run a Node.js app on a Cube as a systemd service that survives reboots, and serve it on your own domain over HTTPS.
A Cube gives you a normal Linux box, so running Node is the ordinary job it would be anywhere: install a runtime, run the app under an init system so it restarts on its own, and put a domain in front of it.
Two things are specific to a Cube and easy to get wrong, so they get their own steps: which Node you install (the one in Ubuntu's archive is past end-of-life), and which interface your app listens on (bind to loopback and your domain returns 503).
This guide fits inside the Welcome tier — the Cube used here is 1 vCPU, 2 GB RAM and 10 GB disk, about $0.0065/hour.
Step 1 — Create the Cube and connect
Create a Cube with the plain Ubuntu 24.04 image and your SSH public key, then connect with the command on its Connect tab:
ssh root@<cube-host> -p <port>Step 2 — Install Node.js
Do not use Ubuntu's packaged Node. On 24.04 apt offers Node 18, which reached end of life in April 2025 — no security updates. Check for yourself:
apt-cache policy nodejsInstall the current LTS from NodeSource instead. Fetch the setup script and read it before running it, rather than piping it into a shell:
curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource.sh
less nodesource.sh
bash nodesource.sh
apt-get install -y nodejsConfirm what you got:
node --version
npm --versionStep 3 — Put the app somewhere sensible
Give the app its own directory and its own unprivileged user, so a compromise of the app is not a compromise of the Cube:
useradd --system --create-home --shell /usr/sbin/nologin nodeapp
mkdir -p /opt/hello-appA minimal /opt/hello-app/server.js to prove the plumbing — replace it with your own app once the path works end to end:
import { createServer } from "node:http";
const port = process.env.PORT ?? 3000;
createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
res.end(`Hello from a Krova Cube.\nnode ${process.version}\n`);
}).listen(port, "0.0.0.0", () => {
console.log(`listening on 0.0.0.0:${port}`);
});The "0.0.0.0" in that listen() call is load-bearing — see Step 5. Add a package.json with "type": "module" so the import works, then hand the directory to the app user:
chown -R nodeapp:nodeapp /opt/hello-appStep 4 — Run it under systemd
Running node server.js in your SSH session lasts exactly as long as the session does. A systemd unit makes the app a real service: it restarts if it crashes and it comes back on its own after a reboot.
Write /etc/systemd/system/hello-app.service:
[Unit]
Description=hello-app
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nodeapp
WorkingDirectory=/opt/hello-app
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node /opt/hello-app/server.js
Restart=always
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.targetThen enable and start it:
systemctl daemon-reload
systemctl enable --now hello-app
systemctl is-active hello-app # active
systemctl is-enabled hello-app # enabledenable is the half people forget. start runs it now; enable is what brings it back after a reboot. The combined enable --now does both.
Step 5 — Bind to 0.0.0.0, not 127.0.0.1
This is the one that costs people an afternoon. An app listening only on 127.0.0.1 cannot be reached through a domain. Krova's ingress runs outside your Cube and connects to it over the network, so a process bound to loopback refuses that connection and the domain answers:
{"error":"upstream_unavailable","message":"The application behind this
domain is starting up or temporarily unreachable. Retry shortly.","status":503}Nothing is wrong with your domain or your certificate when you see that — the request reached Krova and Krova could not reach your app. Check what the app is actually bound to:
ss -ltn | grep 3000127.0.0.1:3000 is the problem; 0.0.0.0:3000 is what you want. Binding to all interfaces is safe here because the Cube is not directly addressable from the internet — only the ports you publish through a domain or a TCP mapping are reachable.
Step 6 — Put a domain in front
Add a DNS record for your domain pointing at dns.krova.cloud, then on the Cube's Networking tab choose Add Domain:
- Domain — e.g.
app.example.com. - Port —
3000, the port your app listens on inside the Cube. The field defaults to80. - This app serves HTTPS itself — leave unchecked. Your app speaks plain HTTP; Krova terminates TLS at its edge and issues and renews the certificate.
Your app never needs a certificate, never needs port 443, and never needs to know its own public name. It listens on a port; Krova does the rest. If your DNS is proxied through Cloudflare, set the zone's SSL/TLS mode to Full — see custom domains.
Step 7 — Prove it survives a reboot
systemctl is-enabled printing enabled is a statement of intent, not evidence. Reboot the Cube and check:
systemctl rebootReconnect after a few seconds and confirm the service came back on its own, without you starting it:
uptime -p # up 0 minutes
systemctl is-active hello-app # active
curl -s http://127.0.0.1:3000/Your domain should serve again as soon as the app is listening. If the service is inactive after a reboot, you started it without enabling it.
Deploying changes
Once the app is a service, a deploy is: update the files, then systemctl restart hello-app. Because Restart=always is set, a crash on startup will loop rather than fail silently — check journalctl -u hello-app -n 50 if a restart does not take.
What it costs
The Cube here (1 vCPU, 2 GB RAM, 10 GB disk) is about $0.0065/hour — roughly $0.16/day. Usage is metered by the minute. A web app is normally something you leave running; if you do power the Cube off, you are billed for its disk alone — see Cubes.
Next steps
- Deploy PostgreSQL on a Cube — give the app a database, on the same Cube or another.
- Install Dokploy on a Cube — if you would rather deploy from Git through a UI than manage systemd units yourself.
- Custom domains — per-domain proxy settings, and the Cloudflare specifics.