Every service on your Ubuntu VPS — Nginx, PostgreSQL, Docker, SSH, cron, your Node.js app, your Python API — is managed by systemd. It starts them on boot, restarts them when they crash, manages their logs, controls their resource usage, and handles their dependencies. If you're running a VPS without understanding systemd, you're operating blind. You can start and stop services, sure, but you can't create custom services, debug why a service won't start, set resource limits, or replace cron with timer units.

This guide covers everything you need to manage systemd on a production Ubuntu VPS: essential commands, custom service files, service types, restart policies, dependency ordering, resource limits with cgroups, environment variables, timer units, and real-world examples for Node.js, Python, Go, and backup scripts.

MassiveGRID Ubuntu VPS includes: Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10

Deploy a self-managed VPS — from $1.99/mo
Need dedicated resources? — from $19.80/mo
Want fully managed hosting? — we handle everything

Why Systemd Matters for VPS Management

On your MassiveGRID Cloud VPS, systemd manages every service. Understanding systemd is the difference between a server you babysit and one that runs itself. Without it, you're left writing ad-hoc shell scripts, using nohup and screen to keep processes running, and manually restarting services after crashes or reboots.

Systemd provides:

Essential systemctl Commands

These are the commands you'll use daily:

Starting and Stopping Services

# Start a service
sudo systemctl start nginx

# Stop a service
sudo systemctl stop nginx

# Restart (stop then start)
sudo systemctl restart nginx

# Reload configuration without stopping (if the service supports it)
sudo systemctl reload nginx

# Reload or restart (reload if supported, otherwise restart)
sudo systemctl reload-or-restart nginx

Enabling and Disabling Services

# Enable (start automatically on boot)
sudo systemctl enable nginx

# Disable (don't start on boot)
sudo systemctl disable nginx

# Enable AND start immediately
sudo systemctl enable --now nginx

# Disable AND stop immediately
sudo systemctl disable --now nginx

Checking Status

# Detailed status
sudo systemctl status nginx

# Is the service active?
systemctl is-active nginx

# Is the service enabled?
systemctl is-enabled nginx

# Is the service failed?
systemctl is-failed nginx

# List all running services
systemctl list-units --type=service --state=running

# List all failed services
systemctl list-units --type=service --state=failed

# List all installed service files
systemctl list-unit-files --type=service

Reading Service Status and Understanding Output

The systemctl status command is your primary diagnostic tool. Here's how to read its output:

sudo systemctl status nginx
● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since Fri 2026-02-28 10:15:32 UTC; 2h 30min ago
       Docs: man:nginx(8)
    Process: 1234 ExecStartPre=/usr/sbin/nginx -t -q -g daemon on; master_process on; (code=exited, status=0/SUCCESS)
   Main PID: 1240 (nginx)
      Tasks: 3 (limit: 4657)
     Memory: 12.5M (peak: 18.2M)
        CPU: 1.234s
     CGroup: /system.slice/nginx.service
             ├─1240 "nginx: master process /usr/sbin/nginx -g daemon on; master_process on;"
             ├─1241 "nginx: worker process"
             └─1242 "nginx: worker process"

Feb 28 10:15:32 vps systemd[1]: Starting nginx.service - A high performance web server...
Feb 28 10:15:32 vps systemd[1]: Started nginx.service - A high performance web server...

Key information in this output:

Field Meaning
Loaded Where the service file is, whether it's enabled, and the preset
Active Current state and how long it's been running
Main PID The primary process ID
Tasks Number of threads/processes (and the limit)
Memory Current and peak memory usage
CPU Total CPU time consumed since start
CGroup Control group and child processes

The colored dot at the beginning indicates status: green (running), red (failed), white (inactive).

Creating a Custom Service File

Service files (unit files) live in /etc/systemd/system/ for custom services. Here's the complete anatomy:

sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My Application Server
Documentation=https://docs.example.com/myapp
After=network.target postgresql.service
Wants=postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
ExecStartPre=/opt/myapp/pre-start.sh
ExecStart=/opt/myapp/bin/server --config /etc/myapp/config.yaml
ExecStartPost=/opt/myapp/post-start.sh
ExecReload=/bin/kill -HUP $MAINPID
ExecStop=/bin/kill -TERM $MAINPID
Restart=on-failure
RestartSec=5
TimeoutStartSec=30
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
Environment=NODE_ENV=production
EnvironmentFile=/etc/myapp/env
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Let's break down each section:

The [Unit] Section

The [Service] Section

The [Install] Section

After creating the file:

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

# Enable and start
sudo systemctl enable --now myapp

# Check status
sudo systemctl status myapp

Service Types

The Type directive tells systemd how to determine when the service has finished starting. Choosing the wrong type causes timeouts and false failure reports.

Type When to Use Examples
simple Process stays in foreground Node.js, Python Gunicorn, Go binaries
forking Process forks and parent exits Nginx (with daemon mode), Apache, legacy daemons
oneshot Process runs once and exits Backup scripts, database migrations, cleanup tasks
notify Process sends sd_notify when ready Services using sd_notify() API
exec Like simple, but waits for exec() to succeed Any service where you want exec validation

simple (Default)

Systemd considers the service started as soon as ExecStart is executed. The process must stay in the foreground (not daemonize). This is correct for most modern applications:

[Service]
Type=simple
ExecStart=/usr/bin/node /opt/myapp/server.js

forking

The process forks a child and the parent exits. Systemd waits for the parent to exit and tracks the child. You must specify PIDFile:

[Service]
Type=forking
PIDFile=/run/nginx.pid
ExecStart=/usr/sbin/nginx

oneshot

For tasks that run to completion and exit. Systemd waits for the process to finish before marking the service as "active". Use RemainAfterExit=yes if you want the service to show as "active" after the process exits:

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/opt/scripts/setup-network.sh

notify

The service sends a notification to systemd when it's ready. This is the most reliable type for services with initialization phases:

[Service]
Type=notify
ExecStart=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main

Restart Policies

The Restart directive controls when systemd restarts a service after it exits:

Policy Restart On Use Case
no Never (default) One-time tasks, manual-only services
always Any exit (clean or crash) Critical services that must always run
on-failure Non-zero exit code, signal, timeout Most application services
on-abnormal Signal, timeout, watchdog Services where clean exit = intentional stop
on-abort Unclean signal only (SIGABRT, SIGSEGV) Services that might crash but shouldn't loop
on-success Clean exit (exit code 0) only Rare — restart only if it exited normally

For production services, use on-failure with RestartSec and start limits:

[Service]
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5

This means: if the service fails, wait 5 seconds and restart it. If it fails more than 5 times in 300 seconds (5 minutes), stop trying and mark the service as failed.

To reset a failed service after fixing the issue:

sudo systemctl reset-failed myapp
sudo systemctl start myapp

Dependencies and Ordering

Systemd has two orthogonal concepts: ordering (when to start) and dependency (whether to start).

Ordering: After / Before

[Unit]
# Start this service AFTER network and PostgreSQL are ready
After=network.target postgresql.service

After only controls order. It does not start PostgreSQL. If PostgreSQL isn't enabled, your service starts after network.target alone.

Dependencies: Wants / Requires / BindsTo

[Unit]
# Soft dependency: try to start PostgreSQL, but don't fail if it can't
Wants=postgresql.service

# Hard dependency: if PostgreSQL fails to start, this service fails too
Requires=postgresql.service

# Tight binding: if PostgreSQL stops or restarts, stop this service too
BindsTo=postgresql.service

Combine them for production services:

[Unit]
Description=Web Application
After=network.target postgresql.service redis.service
Requires=postgresql.service
Wants=redis.service

This means: start after network, PostgreSQL, and Redis. PostgreSQL is required (fail if it's unavailable). Redis is wanted (start it if possible, but don't fail if it's unavailable).

Visualizing Dependencies

# Show what a service depends on
systemctl list-dependencies myapp

# Show what depends on a service (reverse)
systemctl list-dependencies --reverse postgresql.service

# Show the boot order
systemd-analyze critical-chain myapp.service

Resource Limits with cgroups

Systemd uses Linux cgroups (control groups) to limit what resources each service can consume. This is critical on a VPS where multiple services share the same hardware.

Precise resource control: systemd's resource limits give you precise control over how each service uses your guaranteed CPU and RAM on dedicated resources — from $19.80/mo.

CPU Limits

[Service]
# Limit to 50% of one CPU core
CPUQuota=50%

# Limit to 200% (2 full cores on a multi-core VPS)
CPUQuota=200%

# CPU weight (relative priority, default 100)
CPUWeight=50

CPUQuota is a hard limit — the service physically cannot use more. CPUWeight is a relative priority — the service can use more CPU if no other service needs it, but gets deprioritized when there's contention.

Memory Limits

[Service]
# Hard memory limit (service is killed if exceeded)
MemoryMax=1G

# Soft memory limit (triggers reclaim pressure)
MemoryHigh=800M

# Minimum guaranteed memory
MemoryMin=256M

# Swap limit
MemorySwapMax=0

MemoryMax is a hard wall — if the service tries to allocate beyond this, the kernel's OOM killer terminates it. MemoryHigh applies pressure to reclaim memory but doesn't kill. MemorySwapMax=0 prevents the service from using swap (important for latency-sensitive services).

I/O Limits

[Service]
# I/O weight (relative priority, default 100)
IOWeight=50

# Hard I/O bandwidth limits (for specific devices)
IOReadBandwidthMax=/dev/sda 50M
IOWriteBandwidthMax=/dev/sda 20M

# IOPS limits
IOReadIOPSMax=/dev/sda 1000
IOWriteIOPSMax=/dev/sda 500

Other Limits

[Service]
# Max open files
LimitNOFILE=65535

# Max processes/threads
LimitNPROC=4096

# Max tasks (threads + processes) in the cgroup
TasksMax=512

Applying Resource Limits

You can set limits in the service file directly, or use overrides (which survive package updates):

# Create an override for an existing service
sudo systemctl edit nginx.service

This opens an editor. Add your overrides:

[Service]
MemoryMax=512M
CPUQuota=100%

This creates /etc/systemd/system/nginx.service.d/override.conf.

# Verify the override is applied
sudo systemctl cat nginx.service

# Reload and restart
sudo systemctl daemon-reload
sudo systemctl restart nginx

# Check current resource usage
systemctl show nginx.service -p MemoryCurrent,CPUUsageNSec,TasksCurrent

Environment Variables and EnvironmentFile

Services often need environment variables for configuration — database URLs, API keys, ports.

Inline Environment Variables

[Service]
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=DB_HOST=localhost

Environment File (Recommended)

For secrets and complex configuration, use an environment file:

sudo nano /etc/myapp/env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
SECRET_KEY=your-secret-key-here
LOG_LEVEL=info

Secure the file:

sudo chown root:appuser /etc/myapp/env
sudo chmod 640 /etc/myapp/env

Reference it in the service:

[Service]
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node /opt/myapp/server.js

The EnvironmentFile directive supports a - prefix to make it optional (don't fail if the file doesn't exist):

EnvironmentFile=-/etc/myapp/env.local

Timer Units: systemd Alternative to Cron

Systemd timer units are a modern replacement for cron. They support calendar-based scheduling, monotonic timers, randomized delays, and persistent tracking (run missed jobs after reboot). For a comparison with cron, see our cron jobs guide.

Creating a Timer

Timers come in pairs: a .timer file and a .service file. The timer triggers the service.

First, create the service (what to run):

sudo nano /etc/systemd/system/backup.service
[Unit]
Description=Daily backup to remote storage

[Service]
Type=oneshot
User=backup
ExecStart=/opt/scripts/backup.sh
StandardOutput=journal
StandardError=journal

Then create the timer (when to run):

sudo nano /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily at 2 AM

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=900

[Install]
WantedBy=timers.target

Enable the timer (not the service):

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer

# List active timers
systemctl list-timers --all

# Check when the timer will fire next
systemctl list-timers backup.timer

Timer Schedule Syntax

Schedule OnCalendar Value
Every day at midnight *-*-* 00:00:00 or daily
Every hour *-*-* *:00:00 or hourly
Every 15 minutes *-*-* *:00/15:00
Every Monday at 9 AM Mon *-*-* 09:00:00
First of every month *-*-01 00:00:00 or monthly
Every weekday at 6 PM Mon..Fri *-*-* 18:00:00

Validate your schedule:

# Test when a calendar expression fires
systemd-analyze calendar "Mon..Fri *-*-* 18:00:00"

# Output:
#   Original form: Mon..Fri *-*-* 18:00:00
#   Normalized form: Mon..Fri *-*-* 18:00:00
#        Next elapse: Mon 2026-03-02 18:00:00 UTC
#           From now: 2 days left

Monotonic Timers

Instead of clock-based schedules, fire relative to events:

[Timer]
# 10 minutes after boot
OnBootSec=600

# 30 minutes after the timer is activated
OnActiveSec=1800

# Every 6 hours after the last run
OnUnitActiveSec=6h

# Combination: first run 5 min after boot, then every hour
OnBootSec=300
OnUnitActiveSec=1h

Debugging Failed Services

When a service fails, systemd provides powerful diagnostic tools.

journalctl: The Log Viewer

# View logs for a specific service
sudo journalctl -u myapp.service

# Follow logs in real time
sudo journalctl -u myapp.service -f

# Logs since last boot
sudo journalctl -u myapp.service -b

# Logs from the last hour
sudo journalctl -u myapp.service --since "1 hour ago"

# Logs between specific times
sudo journalctl -u myapp.service --since "2026-02-28 10:00" --until "2026-02-28 11:00"

# Show only errors and above
sudo journalctl -u myapp.service -p err

# Show logs with full output (no truncation)
sudo journalctl -u myapp.service --no-pager -l

# Export to a file
sudo journalctl -u myapp.service --since today > /tmp/myapp-logs.txt

For comprehensive log management, see our log management guide.

systemd-analyze: Boot and Service Analysis

# How long did the system take to boot?
systemd-analyze

# Which services took the longest to start?
systemd-analyze blame

# Critical chain (boot path with timings)
systemd-analyze critical-chain

# Critical chain for a specific service
systemd-analyze critical-chain myapp.service

# Verify a service file for syntax errors
systemd-analyze verify /etc/systemd/system/myapp.service

# Show the complete configuration (with overrides)
systemctl cat myapp.service

# Show all properties
systemctl show myapp.service

Common Failure Scenarios

Service fails immediately (status=203/EXEC):

# The executable doesn't exist or isn't executable
ls -la /opt/myapp/bin/server
chmod +x /opt/myapp/bin/server

Service fails with permission denied (status=217/USER):

# The User specified in the service file doesn't exist
id appuser
sudo useradd -r -s /bin/false appuser

Service starts then fails (exit code 1):

# Check the application logs
sudo journalctl -u myapp.service --since "5 minutes ago" --no-pager

# Common causes: missing config file, database unreachable, port already in use
sudo ss -tlnp | grep :3000

Service is in "activating" state forever:

# Wrong Type - service forks but Type=simple expects it to stay in foreground
# Check if the process daemonizes:
ps aux | grep myapp

# Fix: change Type to forking and add PIDFile, or run the app in foreground mode

Real-World Service Examples

Node.js Application

For a Node.js app managed by PM2, see our Node.js deployment guide. For a direct systemd approach without PM2:

sudo nano /etc/systemd/system/nodeapp.service
[Unit]
Description=Node.js Application
Documentation=https://github.com/yourorg/yourapp
After=network.target

[Service]
Type=simple
User=nodeapp
Group=nodeapp
WorkingDirectory=/opt/nodeapp
ExecStart=/home/nodeapp/.nvm/versions/node/v22.14.0/bin/node server.js
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5
EnvironmentFile=/etc/nodeapp/env
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nodeapp
MemoryMax=512M
CPUQuota=100%
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Python Gunicorn Application

For a complete Python deployment, see our Gunicorn deployment guide. Here's the service file:

sudo nano /etc/systemd/system/gunicorn-myapp.service
[Unit]
Description=Gunicorn WSGI Server for MyApp
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=notify
User=myapp
Group=myapp
RuntimeDirectory=gunicorn
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/venv/bin/gunicorn \
    --workers 4 \
    --worker-class gthread \
    --threads 2 \
    --bind unix:/run/gunicorn/myapp.sock \
    --access-logfile - \
    --error-logfile - \
    --timeout 120 \
    wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5
EnvironmentFile=/etc/myapp/env
KillMode=mixed
PrivateTmp=true
MemoryMax=1G

[Install]
WantedBy=multi-user.target

Note: Gunicorn supports Type=notify natively — it signals systemd when workers are ready to accept connections.

Go Binary

sudo nano /etc/systemd/system/goapi.service
[Unit]
Description=Go API Server
After=network.target

[Service]
Type=simple
User=goapi
Group=goapi
ExecStart=/opt/goapi/server
Restart=always
RestartSec=3
EnvironmentFile=/etc/goapi/env
LimitNOFILE=65535
MemoryMax=256M
CPUQuota=100%

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/goapi /var/lib/goapi
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true

[Install]
WantedBy=multi-user.target

The security directives at the bottom (NoNewPrivileges, ProtectSystem, etc.) are systemd's built-in sandboxing. They restrict what the service can access on the filesystem.

Custom Backup Script

sudo nano /etc/systemd/system/db-backup.service
[Unit]
Description=PostgreSQL Database Backup
After=postgresql.service
Requires=postgresql.service

[Service]
Type=oneshot
User=postgres
ExecStart=/opt/scripts/pg-backup.sh
StandardOutput=journal
StandardError=journal
SyslogIdentifier=db-backup
TimeoutStartSec=3600
IOWeight=25
Nice=15
sudo nano /etc/systemd/system/db-backup.timer
[Unit]
Description=Run database backup every 6 hours

[Timer]
OnCalendar=*-*-* 00/6:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
sudo nano /opt/scripts/pg-backup.sh
#!/bin/bash
set -euo pipefail

BACKUP_DIR="/var/backups/postgresql"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
RETAIN_DAYS=7

mkdir -p "$BACKUP_DIR"

# Dump all databases
pg_dumpall | gzip > "$BACKUP_DIR/all_databases_$TIMESTAMP.sql.gz"

# Clean old backups
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +$RETAIN_DAYS -delete

echo "Backup completed: all_databases_$TIMESTAMP.sql.gz"
echo "Size: $(du -h "$BACKUP_DIR/all_databases_$TIMESTAMP.sql.gz" | cut -f1)"
sudo chmod +x /opt/scripts/pg-backup.sh
sudo systemctl daemon-reload
sudo systemctl enable --now db-backup.timer

Security Hardening for Services

Systemd provides built-in sandboxing directives that limit what a service can do. Use them for every custom service:

[Service]
# Prevent gaining new privileges
NoNewPrivileges=true

# Read-only filesystem (except specified paths)
ProtectSystem=strict
ReadWritePaths=/var/lib/myapp /var/log/myapp

# Prevent access to /home, /root, /run/user
ProtectHome=true

# Private /tmp directory (isolated from other services)
PrivateTmp=true

# Prevent kernel tunable modification
ProtectKernelTunables=true

# Prevent kernel module loading
ProtectKernelModules=true

# Prevent cgroup modification
ProtectControlGroups=true

# Restrict network access (only the listed address families)
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

# Restrict system calls
SystemCallFilter=@system-service
SystemCallArchitectures=native

Check how well a service is sandboxed:

# Security audit (higher score = more exposed)
systemd-analyze security myapp.service

# Output shows each directive and its exposure rating
# Aim for "OK" or "MEDIUM" overall

Useful Patterns

Running Pre-Flight Checks

[Service]
ExecStartPre=/usr/bin/test -f /etc/myapp/config.yaml
ExecStartPre=/opt/myapp/bin/validate-config /etc/myapp/config.yaml
ExecStart=/opt/myapp/bin/server --config /etc/myapp/config.yaml

If any ExecStartPre command fails, the service doesn't start.

Graceful Shutdown with Timeout

[Service]
ExecStop=/bin/kill -SIGTERM $MAINPID
TimeoutStopSec=30
KillMode=mixed
KillSignal=SIGTERM
FinalKillSignal=SIGKILL

This sends SIGTERM first, waits 30 seconds for graceful shutdown, then sends SIGKILL to any remaining processes.

Watchdog (Auto-Restart Unresponsive Services)

[Service]
Type=notify
WatchdogSec=30
Restart=on-watchdog

The service must send a heartbeat via sd_notify(0, "WATCHDOG=1") every 30 seconds. If it stops, systemd considers the service hung and restarts it.

Summary

Systemd is the backbone of service management on every modern Ubuntu VPS. Here's a quick reference:

Task Command
Start/stop/restart systemctl start|stop|restart myapp
Enable on boot systemctl enable --now myapp
Check status systemctl status myapp
View logs journalctl -u myapp -f
Create service Write /etc/systemd/system/myapp.service
Override settings systemctl edit myapp
Set resource limits CPUQuota=, MemoryMax=, IOWeight=
Create timer Write .timer + .service pair
Debug failures journalctl -u myapp -p err + systemd-analyze verify
Security audit systemd-analyze security myapp

Master systemd and your VPS runs itself — services start on boot, restart on failure, respect resource limits, and log everything to a centralized journal. Start with a MassiveGRID Cloud VPS where systemd manages every service, upgrade to a Dedicated VPS when you need guaranteed resources for each service's cgroup limits, or go with a Managed Dedicated Cloud Server where our team handles all service management for you.