Deploy a full-stack app with Sistemo

Three VMs on a private network: PostgreSQL, a Node.js API, and Nginx serving a frontend. All on your machine with full VM isolation.

Your machine
├── localhost:8080 -> web VM (Nginx + static frontend)
├── localhost:3030 -> api VM (Node.js + Express)
│   └── db VM (PostgreSQL)     <- api connects here
└── Private "app" network -- DB only from inside VMs, not on host

Time: ~10 minutes. Requirements: Linux with KVM, Docker installed, sistemo running.

Step 1: Create the network

sistemo network create app

All three VMs join this network. They can reach each other by IP, but are isolated from other VMs on your machine.

Step 2: Deploy the database

We deploy a plain Debian VM and install PostgreSQL inside it — just like you would on a real server. This shows the power of sistemo: it's a real Linux VM with apt, systemd, and full package management.

sistemo vm deploy debian --name db --network app --memory 1G --storage 4G

SSH in and set up PostgreSQL:

sistemo vm ssh db

Inside the VM:

# Install PostgreSQL
apt update && DEBIAN_FRONTEND=noninteractive apt install -y postgresql

# Find config files (path varies by version)
PG_CONF=$(find /etc/postgresql -name postgresql.conf | head -1)
PG_HBA=$(find /etc/postgresql -name pg_hba.conf | head -1)

# Allow connections from the network
echo "listen_addresses = '*'" >> "$PG_CONF"
echo "host all all 0.0.0.0/0 md5" >> "$PG_HBA"

# Restart and create the app database
systemctl restart postgresql
su - postgres -c "psql -c \"CREATE USER app WITH PASSWORD 'appsecret';\""
su - postgres -c "psql -c \"CREATE DATABASE appdb OWNER app;\""

# Verify
ss -lntp | grep 5432
exit

Note the db IP for later:

sistemo vm status db | grep IP
# Example: IP: 10.201.0.2

Step 3: Build and deploy the API

Create a simple Express API that connects to PostgreSQL:

mkdir -p /tmp/demo-api && cd /tmp/demo-api

cat > server.js << 'ENDJS'
const express = require("express");
const { Pool } = require("pg");
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

app.get("/api/health", (_, res) => res.json({ status: "ok" }));
app.get("/api/visits", async (_, res) => {
  try {
    await pool.query("CREATE TABLE IF NOT EXISTS visits (id SERIAL, ts TIMESTAMP DEFAULT NOW())");
    await pool.query("INSERT INTO visits DEFAULT VALUES");
    const { rows } = await pool.query("SELECT COUNT(*)::int AS total FROM visits");
    res.json({ total: rows[0].total });
  } catch (err) { res.status(500).json({ error: err.message }); }
});
app.listen(3000, "0.0.0.0", () => console.log("API on :3000"));
ENDJS

cat > Dockerfile << 'EOF'
FROM node:20-slim
WORKDIR /app
COPY server.js .
RUN npm init -y && npm install express pg
CMD ["node", "server.js"]
EOF

docker build -t demo-api .

Build the VM image and deploy:

sudo sistemo image build demo-api
sistemo vm deploy demo-api --name api --network app --memory 512M --expose 3030:3000

Now SSH into the API VM and create a systemd service so it starts automatically and survives restarts:

sistemo vm ssh api

Inside the VM (replace DB_IP with the actual IP from step 2):

cat > /etc/systemd/system/api.service << 'EOF'
[Unit]
Description=Demo API
After=network.target

[Service]
Type=simple
WorkingDirectory=/app
Environment=DATABASE_URL=postgres://app:appsecret@DB_IP:5432/appdb
ExecStart=/usr/local/bin/node /app/server.js
Restart=always

[Install]
WantedBy=multi-user.target
EOF

# Replace DB_IP with actual IP (e.g. 10.201.0.2)
sed -i 's/DB_IP/10.201.0.2/' /etc/systemd/system/api.service

systemctl daemon-reload
systemctl enable --now api
systemctl status api
exit

Test from the host:

curl http://localhost:3030/api/health
# {"status":"ok"}

curl http://localhost:3030/api/visits
# {"total":1}

Step 4: Build and deploy the frontend

mkdir -p /tmp/demo-web && cd /tmp/demo-web

cat > index.html << 'EOF'
<!DOCTYPE html>
<html>
<head>
  <title>Sistemo Demo</title>
  <style>
    body { font-family: system-ui; max-width: 600px; margin: 60px auto; padding: 0 20px; }
    button { padding: 12px 24px; font-size: 16px; cursor: pointer; border-radius: 8px; }
    pre { background: #f4f4f4; padding: 16px; border-radius: 8px; }
  </style>
</head>
<body>
  <h1>Sistemo Full-Stack Demo</h1>
  <p>Each click writes a row to PostgreSQL through the API.</p>
  <button onclick="visit()">Count Visit</button>
  <pre id="result">Click the button...</pre>
  <script>
    async function visit() {
      try {
        const res = await fetch("/api/visits");
        const data = await res.json();
        document.getElementById("result").textContent = JSON.stringify(data, null, 2);
      } catch (e) {
        document.getElementById("result").textContent = "Error: " + e.message;
      }
    }
  </script>
</body>
</html>
EOF

cat > Dockerfile << 'EOF'
FROM nginx:stable
COPY index.html /usr/share/nginx/html/
EOF

docker build -t demo-web .

Build and deploy:

sudo sistemo image build demo-web
sistemo vm deploy demo-web --name web --network app --memory 256M --expose 8080:80

Configure Nginx to proxy API requests. SSH in and set up the reverse proxy (replace API_IP with the actual IP from step 3):

sistemo vm ssh web

Inside the VM:

cat > /etc/nginx/conf.d/default.conf << 'CONF'
server {
    listen 80;
    root /usr/share/nginx/html;

    location /api/ {
        proxy_pass http://API_IP:3000;
        proxy_set_header Host $host;
    }
    location / {
        try_files $uri /index.html;
    }
}
CONF

# Replace API_IP with actual IP (e.g. 10.201.0.3)
sed -i 's/API_IP/10.201.0.3/' /etc/nginx/conf.d/default.conf
nginx -t && nginx -s reload
exit

Step 5: Test the full stack

Open http://localhost:8080 in your browser. Click “Count Visit” — each click writes to PostgreSQL through the API.

Sistemo Full-Stack Demo — browser showing visit counter at 20, connected to PostgreSQL through the Node.js API
# From the command line
curl http://localhost:8080                    # frontend HTML
curl http://localhost:3030/api/health         # API health check
curl http://localhost:3030/api/visits         # increment counter

sistemo vm list

After you deploy

You now have three microVMs on the private app network: PostgreSQL, the API, and Nginx. From your host, only two ports are published — everything else stays inside the VMs.

  • Frontend (Nginx): http://localhost:8080 — static UI; /api/* is proxied to the API VM.
  • API (Express): http://localhost:3030 — use /api/health and /api/visits for checks.
  • Database: PostgreSQL listens on :5432 inside the db VM only — not forwarded to the host.

After you finish deploying, registry and built images are stored on disk under ~/.sistemo/images. You should see your pulled base image plus the API and web rootfs files you built:

ls -lha ~/.sistemo/images
total 1.2G
drwxr-xr-x 2 ds ds 4.0K Mar 22 20:29 .
drwxr-xr-x 8 ds ds 4.0K Mar 22 21:20 ..
-rw-rw-r-- 1 ds ds 512M Mar 22 17:45 debian.rootfs.ext4
-rw-r--r-- 1 ds ds 2.0G Mar 22 21:23 demo-api.rootfs.ext4
-rw-r--r-- 1 ds ds 2.0G Mar 22 21:24 demo-web.rootfs.ext4
All three VMs are on the app network. The database is only reachable from the API and web VMs — not from the host or other VMs. Only ports 8080 (web) and 3030 (api) are exposed to the host.

Operate

# SSH into any VM
sistemo vm ssh db
sistemo vm ssh api
sistemo vm ssh web

# Stop and start (port rules auto-restored)
sistemo vm stop api
sistemo vm start api

# View history
sistemo history

Cleanup

sistemo vm delete web
sistemo vm delete api
sistemo vm delete db
sistemo network delete app

Everything gone. No leftover containers, volumes, or networks.

FeatureHow it's used
Docker → VMsistemo image build converts Docker images to bootable VMs
Private networksAll VMs on “app” network, isolated from default bridge
Port exposureHost ports 8080 (web) and 3030 (api) exposed
Real systemdAPI runs as a systemd service, survives restarts
Full Linuxapt, systemctl, SSH — standard Linux administration