Guide · Deployment

MCP server systemd

systemd is the init system on virtually every modern Linux distribution. Running your MCP server as a systemd service gives you automatic start on boot, automatic restart on crash, centralised log management via journald, and process isolation without a Docker daemon or PM2 process manager. systemd has one interaction specific to MCP servers: TimeoutStopSec controls how long systemd waits for the service to exit after sending SIGTERM before escalating to SIGKILL. If this timeout is shorter than your session drain window, systemd force-kills the process before active SSE sessions have closed. This guide covers the full unit file, environment variable injection for secrets, journald log forwarding, and security hardening directives.

TL;DR

Create a service unit file at /etc/systemd/system/mcp-server.service. Set Restart=on-failure with RestartSec=5s and exponential backoff via StartLimitIntervalSec. Set TimeoutStopSec=35 (5 seconds more than your DRAIN_TIMEOUT_MS) so systemd waits for session drain before SIGKILL. Use EnvironmentFile=/etc/mcp-server/env for secrets. Run as a dedicated non-root user. Enable with systemctl enable --now mcp-server.

Service unit file

# /etc/systemd/system/mcp-server.service

[Unit]
Description=MCP Server (HTTP/SSE)
Documentation=https://alivemcp.com/seo/mcp-server-systemd
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
# Type=notify requires the application to call sd_notify(READY=1) or equivalent
# For Node.js, use the 'sd-notify' npm package to send the ready notification
# If not using sd_notify, set Type=simple and remove the ExecStartPost health check

User=mcp
Group=mcp
WorkingDirectory=/opt/mcp-server

# The main process — runs as the mcp user, inherits the environment below
ExecStart=/usr/bin/node \
  --max-old-space-size=400 \
  dist/server.js

# Inject secrets and configuration from a file outside the repository
# /etc/mcp-server/env is owned by root, readable only by root and the mcp user
EnvironmentFile=/etc/mcp-server/env

# Standard environment variables
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=LOG_LEVEL=info
Environment=DRAIN_TIMEOUT_MS=25000

# Restart policy — restart on non-zero exit or signal kill, but not on clean exit
Restart=on-failure
RestartSec=5s

# Exponential back-off: if the service restarts more than 5 times in 5 minutes,
# systemd stops trying and marks it as failed (prevents runaway crash loops)
StartLimitIntervalSec=300
StartLimitBurst=5

# Critical: give the process enough time to drain SSE sessions before SIGKILL
# Must be greater than DRAIN_TIMEOUT_MS (25000ms = 25s)
# Add 5-10 seconds buffer for the drain logic setup overhead
TimeoutStopSec=35

# stdout/stderr go to the systemd journal (viewable with journalctl)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mcp-server

# ── Security hardening ──────────────────────────────────────────────────────
# Run with a private /tmp (cannot write to the system /tmp)
PrivateTmp=true

# Prevent the service from gaining new capabilities
NoNewPrivileges=true

# Mount /usr, /boot, /etc read-only (the service cannot modify system files)
ProtectSystem=strict

# The service can read but not write to /home
ProtectHome=read-only

# Allow writes only to these directories (journal + data directory)
ReadWritePaths=/var/log/mcp-server /opt/mcp-server/data

# Disable access to kernel interfaces not needed by the MCP server
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true

# Restrict system call families to those needed by Node.js
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target

The Type=notify setting tells systemd to wait for the process to call sd_notify("READY=1") before marking it as started. This is the systemd equivalent of PM2's wait_ready / process.send('ready') pattern. Until the service sends the ready notification, systemd considers it starting and does not route traffic or start dependent services. The Type=notify integration requires the sd-notify npm package; without it, use Type=simple and remove the notification call.

Node.js systemd notify integration

// src/server.ts — systemd Type=notify integration
import { notify } from 'sd-notify';   // npm install sd-notify

async function start() {
  await initDatabase();
  await loadSecrets();
  await buildMcpServer();

  const server = await app.listen({ port: 3000, host: '0.0.0.0' });

  // Tell systemd the service is ready to accept connections
  // systemd sets NOTIFY_SOCKET in the environment when Type=notify
  notify({ ready: 1, status: 'MCP server ready on port 3000' });
  app.log.info('systemd: READY=1 sent');
}

// Graceful shutdown — systemd sends SIGTERM when 'systemctl stop mcp-server' is called
// or when the system shuts down (SIGTERM → drain → process.exit → SIGKILL if TimeoutStopSec exceeded)
process.on('SIGTERM', async () => {
  app.log.info('SIGTERM received — draining sessions');

  // Tell systemd we are stopping (optional but good practice)
  notify({ stopping: 1 });

  await drainSessions(25000);  // must complete within TimeoutStopSec (35s)
  process.exit(0);
});

If sd-notify is not available or you want to avoid the dependency, switch to Type=simple in the unit file. With Type=simple, systemd considers the service started as soon as the process spawns — no ready notification required. The trade-off is that systemd cannot distinguish "process started but not yet ready" from "process ready to serve traffic". For MCP servers with slow startup (database migration, JWKS cache warm-up), Type=notify prevents systemd from marking the service as running before it can actually accept connections.

Environment file for secrets

# Create the environment file directory and set permissions
install -d -m 750 -o root -g mcp /etc/mcp-server

# Create the environment file
cat > /etc/mcp-server/env << 'EOF'
JWT_SECRET=your-256-bit-jwt-secret
WEBHOOK_SIGNING_KEY=your-webhook-key
DATABASE_URL=sqlite:///opt/mcp-server/data/mcp.db
ALIVEMCP_PROBE_KEY=mcp_live_...
EOF

# Only root can write, root and mcp group can read
chmod 640 /etc/mcp-server/env
chown root:mcp /etc/mcp-server/env

The EnvironmentFile directive reads key=value pairs and injects them as environment variables into the service process. The file is read once at service start — updating the file requires systemctl daemon-reload && systemctl restart mcp-server to take effect. Unlike PM2's ecosystem file (which is often committed to git), the environment file lives in /etc/ and is owned by root — not in the application repository. See MCP server secrets management for credential rotation without service restart using a watched secrets file.

Creating the mcp user

# Create a dedicated system user for the MCP server
# --system: creates a system user (no home directory, no shell, no login)
# --group: creates a matching group
# --no-create-home: no home directory (the service uses /opt/mcp-server)
useradd --system --group --no-create-home mcp

# Create the application directory and set ownership
install -d -m 755 -o mcp -g mcp /opt/mcp-server
install -d -m 755 -o mcp -g mcp /opt/mcp-server/data

# Deploy the application files as the mcp user
rsync -av --chown=mcp:mcp dist/ /opt/mcp-server/dist/
rsync -av --chown=mcp:mcp node_modules/ /opt/mcp-server/node_modules/
cp --preserve=ownership package.json /opt/mcp-server/

The systemd hardening directives (NoNewPrivileges, ProtectSystem=strict) are most effective when the service runs as a non-root user. A root-owned service with ProtectSystem=strict is still more isolated than without it, but a non-root service cannot escalate privileges even if the Node.js process is compromised by a tool handler vulnerability.

Enable and manage the service

# Copy the unit file (or create a symlink)
cp mcp-server.service /etc/systemd/system/

# Reload the systemd daemon to pick up the new unit file
systemctl daemon-reload

# Enable the service to start on boot AND start it now
systemctl enable --now mcp-server

# Check status
systemctl status mcp-server

# View recent logs (last 100 lines)
journalctl -u mcp-server --lines 100

# Follow logs in real time (like tail -f)
journalctl -u mcp-server -f

# Filter logs by JSON field (requires journald JSON forwarding — see below)
journalctl -u mcp-server --output json | jq 'select(.level == "error")'

# Graceful stop (sends SIGTERM, waits for TimeoutStopSec, then SIGKILL)
systemctl stop mcp-server

# Reload after updating the binary (sends SIGHUP — not the same as restart)
# If your MCP server handles SIGHUP as a config reload, this is a no-downtime update
# If not, use restart instead
systemctl restart mcp-server

# After modifying the unit file:
systemctl daemon-reload && systemctl restart mcp-server

Log management with journald

systemd captures all stdout/stderr output and stores it in the systemd journal. The journal is structured and queryable. For long-term log retention and aggregation, forward the journal to a log aggregation stack.

# Configure journald to forward to a remote log aggregator
# /etc/systemd/journald.conf.d/mcp-forward.conf
[Journal]
ForwardToSyslog=yes     # forward to syslog (then ship via rsyslog/syslog-ng)
MaxRetentionSec=7day    # keep 7 days of journal on disk
MaxFileSec=1day         # rotate into a new journal file daily

# Alternatively, use systemd-journal-upload to forward to a remote journal server:
apt install systemd-journal-remote
# Configure /etc/systemd/journal-upload.conf with your remote server URL

For production MCP servers, the simplest log aggregation path on a VPS is: Node.js writes JSON to stdout → journald captures it → Promtail reads the journal via journald service discovery → Promtail ships to Grafana Loki. The SyslogIdentifier=mcp-server field in the unit file labels every log line so Promtail can filter by service. See MCP server log aggregation for the Promtail journald configuration.

Deployment update workflow

#!/bin/bash
# deploy.sh — update the MCP server binary and restart gracefully

set -e

APP_DIR=/opt/mcp-server
BACKUP_DIR=/opt/mcp-server-backup/$(date +%Y%m%d-%H%M%S)

# Build the new version locally
npm run build

# Backup the current binary
mkdir -p "$BACKUP_DIR"
cp -r "$APP_DIR/dist/" "$BACKUP_DIR/"

# Deploy the new binary
rsync -av --chown=mcp:mcp dist/ "$APP_DIR/dist/"
rsync -av --chown=mcp:mcp --delete node_modules/ "$APP_DIR/node_modules/"

# Graceful restart — systemd sends SIGTERM, waits for TimeoutStopSec, then starts new process
sudo systemctl restart mcp-server

# Wait for the service to become active
timeout 30 bash -c 'until systemctl is-active mcp-server; do sleep 1; done'

# Smoke test — verify the new version is serving correctly
curl -sf http://localhost:3000/health | grep '"status":"ok"' || {
  echo "Health check failed — rolling back"
  cp -r "$BACKUP_DIR/dist/" "$APP_DIR/dist/"
  sudo systemctl restart mcp-server
  exit 1
}

echo "Deploy complete"

This deploy script is a simple rolling restart — it stops the old process and starts the new one. Active SSE sessions are disrupted (the SIGTERM drain handler gives them up to 25 seconds to close). For zero-downtime deploys on a single server, use PM2 reload with wait_ready instead — PM2 starts the new process before stopping the old one. systemd does not natively support this "start new before stop old" pattern without a second service unit. See MCP server zero-downtime deployment for the options.

Related questions

Should I use systemd directly or PM2 on a Linux VPS?

systemd is the better choice for production MCP servers on Linux. It is built into the OS, has no external dependency, handles automatic restart and startup natively, integrates with journald for log management, and provides security sandboxing directives that PM2 does not. PM2 is more convenient during development (easier log tailing, pm2 monit, pm2 reload for zero-downtime on a single server) but adds a layer of indirection in production. If zero-downtime rolling restart on a single machine is a requirement, use PM2 for its wait_ready + pm2 reload sequence. If not, systemd is simpler and more robust. See MCP server PM2 for the comparison.

What happens when TimeoutStopSec expires?

systemd sends SIGKILL to the process and all its child processes. SIGKILL cannot be caught — the process exits immediately, all open SSE connections are terminated without a drain, and any in-progress SQLite writes may be left in an inconsistent state (SQLite is ACID-compliant and will recover on next start, but partial writes may be lost). Set TimeoutStopSec conservatively — if sessions typically last under 2 minutes, a 30-second drain window handles the 99th percentile. Only very long-lived sessions (agent workflows running for 10+ minutes) require longer timeouts, and those should use blue-green rather than relying on drain windows.

How do I view logs for a systemd MCP server?

Use journalctl -u mcp-server. Add -f to follow in real time (like tail -f). Add --since "1 hour ago" to filter by time. Add --output json to get structured JSON output that you can pipe to jq. The journal retains logs up to the MaxRetentionSec limit in journald.conf (default is until disk is 10% full or 4 GB, whichever comes first). For persistent logs across reboots, check that /var/log/journal/ exists and journald is configured for persistent storage (Storage=persistent in journald.conf).

Can I run multiple MCP servers under systemd on the same host?

Yes — create one unit file per server: mcp-server-public.service, mcp-server-admin.service, etc. Each service runs on a different port (3000, 3001) as a different user with a different EnvironmentFile. nginx routes to each upstream based on the server_name (domain or subdomain). Instantiated services (systemd templates) can reduce duplication: create mcp-server@.service with %i placeholders for the instance name, then enable with systemctl enable mcp-server@public and systemctl enable mcp-server@admin.

Further reading