Bare-metal / systemd
For when you own the server — bare-metal hardware, VPS, or a VM you SSH into. systemd as the supervisor of the Omnitron daemon; Omnitron supervises the Titan apps.
Topology
systemd = OS-level supervisor. Omnitron = app-level supervisor. Each does its job.
Prerequisites
# Node.js 22+ (via nvm or apt):
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# pnpm:
sudo npm install -g pnpm
# Postgres + Redis (or managed elsewhere):
sudo apt install -y postgresql redis
# Optional but nice — a dedicated user:
sudo useradd -r -m -s /bin/bash omnitron
sudo -u omnitron mkdir -p /home/omnitron/.omnitron
Ship the build
# On the build host:
pnpm install --frozen-lockfile
pnpm build
tar -czf platform.tar.gz dist apps packages omnitron.config.ts package.json pnpm-lock.yaml
# To the server:
scp platform.tar.gz omnitron@server:/home/omnitron/
ssh omnitron@server
tar -xzf platform.tar.gz -C /home/omnitron/platform/
cd /home/omnitron/platform
pnpm install --frozen-lockfile --prod
For mature shops, replace with a real deploy pipeline
(omnitron deploy build <app> — which writes a versioned tarball
to <projectRoot>/.omnitron/artifacts/<app>-<version>.tar.gz —
plus scp + atomic symlink swap).
systemd unit
Create /etc/systemd/system/omnitron.service:
[Unit]
Description=Omnitron supervisor
Documentation=https://omnitron.dev/docs
After=network-online.target postgresql.service redis.service
Wants=network-online.target
[Service]
Type=notify
User=omnitron
Group=omnitron
WorkingDirectory=/home/omnitron/platform
# Environment from a file (do NOT commit this file):
EnvironmentFile=/etc/omnitron/env
# Boot the daemon in foreground; systemd handles restart:
ExecStart=/usr/bin/pnpm omnitron up --foreground
# Graceful shutdown — give time for in-flight requests to drain:
KillSignal=SIGTERM
TimeoutStopSec=60
KillMode=mixed
Restart=on-failure
RestartSec=5s
StartLimitInterval=60s
StartLimitBurst=5
# Hardening:
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/home/omnitron/.omnitron /var/log/omnitron
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
# Resource limits:
LimitNOFILE=65536
MemoryMax=4G
CPUQuota=200% # 2 cores
[Install]
WantedBy=multi-user.target
Environment file /etc/omnitron/env:
NODE_ENV=production
DATABASE_URL=postgres://platform:strongpass@localhost:5432/platform
REDIS_URL=redis://localhost:6379
JWT_SECRET=<generated>
# Daemon state lives at $HOME/.omnitron — with User=omnitron that resolves to
# /home/omnitron/.omnitron. There is no OMNITRON_HOME override; set HOME to relocate.
HOME=/home/omnitron
Set restrictive perms:
sudo chmod 0600 /etc/omnitron/env
sudo chown omnitron:omnitron /etc/omnitron/env
Enable + start
sudo systemctl daemon-reload
sudo systemctl enable omnitron
sudo systemctl start omnitron
# Watch logs:
sudo journalctl -u omnitron -f
The Omnitron daemon writes its own log to
~/.omnitron/logs/omnitron.log (errors also to
~/.omnitron/logs/omnitron.error.log); journald captures
stdout/stderr as a backup.
Database setup
sudo -u postgres psql <<'SQL'
CREATE USER platform WITH PASSWORD 'strongpass';
CREATE DATABASE platform OWNER platform;
GRANT ALL PRIVILEGES ON DATABASE platform TO platform;
SQL
# Migrate:
sudo -u omnitron bash -c 'cd /home/omnitron/platform && pnpm omnitron infra migrate'
For better security, use Postgres' pg_hba.conf to restrict
auth method per source.
Reverse proxy (nginx)
The webapp_backend below assumes the Omnitron Console is
running on :9800. That port is served by the omnitron-nginx
container that omnitron webapp start provisions (it proxies to
the daemon's Netron HTTP on :9801 / WS on :9802), so the
Console still needs Docker on the host. If you don't want Docker
on a bare-metal box, drop the webapp_backend/app.example.com
server block and operate via the CLI + the daemon RPC on :9801.
# /etc/nginx/sites-available/platform
upstream api_backend {
server 127.0.0.1:3001;
keepalive 32;
}
upstream webapp_backend {
server 127.0.0.1:9800; # omnitron-nginx (Console) — needs `omnitron webapp start`
keepalive 16;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
proxy_read_timeout 7d;
}
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://webapp_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
server {
listen 80;
server_name api.example.com app.example.com;
return 301 https://$server_name$request_uri;
}
sudo ln -sf /etc/nginx/sites-available/platform /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
TLS via Let's Encrypt
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com -d app.example.com
Cert auto-renews via certbot's timer.
Updating
# Atomic deploy via symlink swap:
ssh omnitron@server <<'EOF'
cd /home/omnitron
NEW=platform-$(date +%s)
mkdir -p $NEW
tar -xzf /home/omnitron/platform.tar.gz -C $NEW
cd $NEW && pnpm install --frozen-lockfile --prod && pnpm omnitron infra migrate
cd /home/omnitron
ln -sfn $NEW platform-next
mv -T platform-next platform
sudo systemctl reload omnitron # or restart if needed
# Keep previous 5 for rollback:
ls -t platform-* | tail -n +6 | xargs rm -rf
EOF
For zero-downtime reload (workers cycle, daemon stays up):
sudo systemctl reload omnitron # if unit declares ExecReload
# or directly:
sudo -u omnitron pnpm omnitron reload
Backups
Daily Postgres backup via cron:
# /etc/cron.d/omnitron-backups
30 2 * * * postgres pg_dump platform | gzip > /var/backups/platform-$(date +\%Y\%m\%d).sql.gz
Or let the daemon schedule it. omnitron backup schedule takes a
target and a specification, persists it, and re-arms it on every
daemon start:
omnitron backup schedule main "0 3 * * *" # cron, or hourly|daily|weekly, or a number of ms
omnitron backup schedule all daily # 'all' = every database in a running stack
omnitron backup schedules # list what is configured
omnitron backup unschedule main
An unparseable specification is rejected when you set it, rather than defaulting to some time nobody chose.
Cron on the host works too, and is the right choice when you want the backup to run whether or not the daemon is up:
# /etc/cron.d/omnitron-app-backups — 'main' is the app database name
0 3 * * * omnitron cd /home/omnitron/platform && /usr/bin/pnpm omnitron backup create main
The daemon's state under ~/.omnitron/ (including
the SQLite store at ~/.omnitron/data/daemon-state.db) is
regenerable — back it up if you want history (uptime bars,
secrets store).
Multi-server fleet
Omnitron supports a fleet of daemons addressable by alias:
# On the master:
omnitron remote add web-1 192.168.1.10 --port 9700
omnitron remote add web-2 192.168.1.11 --port 9700
omnitron remote status web-1
omnitron fleet status
Each omnitron up daemon listens on TCP 9700 (configurable) for
fleet RPC; the master coordinates.
For cluster mode (leader election), configure the daemon's
cluster block — not env vars. omnitron cluster status
itself prompts with the config shape:
// daemon cluster config
cluster: {
enabled: true,
discovery: 'redis', // 'redis' | 'static'
peers: ['192.168.1.10:9700', '192.168.1.11:9700', '192.168.1.12:9700'],
}
Inspect/manage it with omnitron cluster status and
omnitron cluster step-down. (There are no
OMNITRON_CLUSTER_* environment variables.)
See Cluster + Fleet.
Monitoring
- Application metrics — Prometheus scrapes the daemon's
dedicated metrics port
:9803/metrics(loopback by default; bind0.0.0.0+ bearer token to scrape off-host).:9803also serves/healthz. - Host metrics — node_exporter on each host.
- Logs — Promtail / Filebeat tails journald + Omnitron's log directory.
- Alerts — Prometheus AlertManager + Omnitron's own
OmnitronAlerts.
Firewall
# UFW recipe:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP (redirects to HTTPS)
sudo ufw allow 443/tcp # HTTPS
sudo ufw allow from 192.168.1.0/24 to any port 9700 # Omnitron TCP — internal only
sudo ufw enable
Public exposure of 9700 = full operator surface on the internet. Never.
Hardening checklist
- Run as a non-root user (
omnitronin examples above). - systemd hardening directives (above).
-
chmod 0600 /etc/omnitron/env. - Firewall closed to 9700 from outside.
- TLS on nginx (Let's Encrypt or your CA).
- Postgres
pg_hba.confrestricts auth. - SSH key auth only; password auth disabled.
- fail2ban on SSH + nginx.
- Auto-security-updates for the OS (
unattended-upgrades). - Periodic vulnerability scan (
pnpm audit).
When bare-metal beats Docker/k8s
- Predictable latency — no container abstraction overhead.
- Direct hardware access — GPUs, custom NICs, NVMe.
- Cost — at scale, raw VPS beats managed.
- Simplicity — one server, one systemd unit, one nginx.
When it doesn't:
- Horizontal scaling — pets vs cattle; bare-metal is pets.
- Crash recovery — no automatic node replacement.
- Multi-region — need to roll your own DNS / failover.
See also
- Deployment overview
- Docker Compose — containerised alternative
- Cluster + Fleet — multi-server patterns
- Best practices