How to Create a Systemd Service
Run Node.js, Python, Go, or any custom app as a proper Linux service that starts on boot, auto-restarts on crash, and logs to journalctl.
If you’re running a Node.js app, a Python script, a Go binary, or anything else that needs to stay alive, don’t use screen or nohup. Create a systemd service. This applies to any GoZen VPS or dedicated server. It starts on boot, restarts on crash, logs properly, and gives you systemctl start/stop/restart like any other service.
The Basics
Systemd service files live in /etc/systemd/system/. Each file describes one service - what to run, as which user, when to start, and what to do if it crashes.
Minimal Service File
sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My Web Application
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/myapp
ExecStart=/usr/bin/node server.js
Restart=on-failure
[Install]
WantedBy=multi-user.target
# Reload systemd so it picks up the new file
sudo systemctl daemon-reload
# Start the service
sudo systemctl start myapp
# Enable it on boot
sudo systemctl enable myapp
# Check status
sudo systemctl status myapp
That’s it. Your app is now a proper service.
Real-World Examples
Node.js App
[Unit]
Description=Node.js API Server
After=network.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/api
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=node-api
# Security
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/deploy/api/logs /home/deploy/api/uploads
[Install]
WantedBy=multi-user.target
Python App (with virtualenv)
[Unit]
Description=Python Flask Application
After=network.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/webapp
ExecStart=/home/deploy/webapp/venv/bin/gunicorn --workers 3 --bind 0.0.0.0:8000 app:app
Restart=always
RestartSec=5
Environment=FLASK_ENV=production
StandardOutput=journal
StandardError=journal
SyslogIdentifier=flask-app
[Install]
WantedBy=multi-user.target
Go Binary
[Unit]
Description=Go Microservice
After=network.target
[Service]
Type=simple
User=deploy
Group=deploy
ExecStart=/home/deploy/goapp/server
Restart=always
RestartSec=3
Environment=APP_PORT=8080
Environment=APP_LOG_LEVEL=info
StandardOutput=journal
StandardError=journal
SyslogIdentifier=goapp
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
Key Directives Explained
[Unit] Section
| Directive | What It Does |
|---|---|
Description= | Human-readable name (shows in systemctl status) |
After=network.target | Wait for networking before starting |
After=mysql.service | Wait for MySQL to start first |
Requires=mysql.service | Won’t start unless MySQL is running |
Wants=redis.service | Starts Redis too, but doesn’t fail if Redis fails |
[Service] Section
| Directive | What It Does |
|---|---|
Type=simple | The default. Your command is the main process. |
Type=forking | For daemons that fork (background themselves). Use PIDFile= with this. |
User= / Group= | Run as this user. Never use root unless absolutely necessary. |
WorkingDirectory= | cd to this directory before running ExecStart |
ExecStart= | The command to run. Must be an absolute path. |
ExecStartPre= | Run before the main command (e.g., run migrations) |
ExecStop= | Custom stop command (default: sends SIGTERM) |
Restart=always | Restart on any exit (crash, signal, clean exit) |
Restart=on-failure | Only restart on non-zero exit or signal |
RestartSec=5 | Wait 5 seconds between restarts |
Environment= | Set environment variables |
EnvironmentFile= | Load env vars from a file (great for secrets) |
[Install] Section
| Directive | What It Does |
|---|---|
WantedBy=multi-user.target | Start in normal multi-user mode (the standard for servers) |
Using Environment Files
Don’t put secrets in the service file. Use an environment file instead:
sudo nano /home/deploy/myapp/.env
DATABASE_URL=mysql://user:password@localhost:3306/mydb
SECRET_KEY=your-secret-key-here
REDIS_URL=redis://localhost:6379
# Restrict permissions
sudo chmod 600 /home/deploy/myapp/.env
sudo chown deploy:deploy /home/deploy/myapp/.env
Reference it in the service file:
[Service]
EnvironmentFile=/home/deploy/myapp/.env
Managing Your Service
# Start / stop / restart
sudo systemctl start myapp
sudo systemctl stop myapp
sudo systemctl restart myapp
# Reload without full restart (if your app supports it)
sudo systemctl reload myapp
# Check status
sudo systemctl status myapp
# View logs
journalctl -u myapp -f # follow live
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err # errors only
# Enable / disable on boot
sudo systemctl enable myapp
sudo systemctl disable myapp
# After editing the service file
sudo systemctl daemon-reload
sudo systemctl restart myapp
Restart Limits
By default, systemd stops restarting a service if it crashes too many times in a short period (5 times in 10 seconds). You can adjust this:
[Service]
Restart=always
RestartSec=5
# Allow up to 10 restarts in 60 seconds before giving up
StartLimitIntervalSec=60
StartLimitBurst=10
If systemd stops restarting your service, you’ll see “start request repeated too quickly” in the logs. Fix the underlying crash, then:
sudo systemctl reset-failed myapp
sudo systemctl start myapp
Security Hardening
Systemd offers process isolation features. Use them - they cost nothing and reduce the blast radius if your app gets compromised:
[Service]
# Drop all capabilities except what's needed
NoNewPrivileges=true
# Read-only filesystem (except specified paths)
ProtectSystem=strict
ReadWritePaths=/home/deploy/myapp/data /home/deploy/myapp/logs
# Isolate from home directories
ProtectHome=read-only
# No access to /tmp (gets its own tmp)
PrivateTmp=true
# Can't modify kernel variables
ProtectKernelTunables=true
# Can't load kernel modules
ProtectKernelModules=true
# Restrict network protocols
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
Test after adding hardening options - some apps need access to directories you’ve just restricted.
Troubleshooting
| Problem | Fix |
|---|---|
| “Failed to start” - exit code 217 | User doesn’t exist. Check the User= line. |
| “Failed to start” - exit code 203 | ExecStart binary not found. Use absolute paths (/usr/bin/node, not node). Find it with which node. |
| Service starts then immediately stops | Check logs: journalctl -u myapp -n 50. Usually a crash in the app itself, not a systemd issue. |
| “Start request repeated too quickly” | The app is crash-looping. Fix the app bug, then systemctl reset-failed myapp. |
| Environment variables not loading | Use EnvironmentFile= with absolute path. The file must be readable by root (systemd reads it before switching users). |
| Service runs fine manually but fails in systemd | Your shell has PATH and env vars that systemd doesn’t. Use full paths in ExecStart and set all env vars explicitly. |
| “Permission denied” on files | Check User= and Group=. The service runs as that user, not as you. Fix ownership: chown deploy:deploy /path/to/files. |
| Can’t bind to port 80/443 | Ports below 1024 need root or CAP_NET_BIND_SERVICE. Better approach: run on 3000+ and use Nginx as a reverse proxy. |
Related Articles
Last updated 21 Apr 2026, 08:08 +0300.