Skip to main contentYour first top-up gets $5 in bonus credit — one-time, for every new account. Create your account
Krova CloudKrova Cloud

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 nodejs

Install 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 nodejs

Confirm what you got:

node --version
npm --version

Step 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-app

A 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-app

Step 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.target

Then enable and start it:

systemctl daemon-reload
systemctl enable --now hello-app
systemctl is-active hello-app    # active
systemctl is-enabled hello-app   # enabled

enable 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 3000

127.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.
  • Port3000, the port your app listens on inside the Cube. The field defaults to 80.
  • 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 reboot

Reconnect 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