Run Node.js in Production with systemd

Khimananda Oli 8 min read Programming and Languages
Run Node.js in Production with systemd

By Khimananda Oli | Last reviewed: August 2026

Running a raw node server.js command via SSH or screen is a liability that eventually causes outages. To reliably run Node.js in production with systemd, you must leverage the native init system to handle process resurrection, log aggregation, and resource isolation without third-party wrappers. This approach transforms your application from a fragile script into a managed system service that survives reboots and crashes automatically.

Before configuring the service itself, verify your foundation is solid. A properly configured runtime environment prevents subtle permission errors and version mismatches that plague many deployments. If you are setting up a fresh server, follow the steps in our guide to install Node.js on Ubuntu to ensure you have a stable binary installed globally or via a version manager accessible to the service user. Never run production applications as root; always create a dedicated user for your application to limit the blast radius of any potential compromise.

systemdPID 1 / InitNode.js App/opt/app/server.jsjournaldBinary LogsRestart=always
Systemd supervises the Node.js process, capturing stdout to journald and enforcing restart policies automatically.

How do you configure a systemd unit file to run Node.js in production?

The unit file is the single source of truth for how the operating system treats your application. When you run Node.js in production with systemd, this configuration determines everything from which user owns the process to what happens when memory runs low. Create the file at /etc/systemd/system/myapp.service. Avoid placing custom units in /lib/systemd/system/, as package updates may overwrite them.

[Unit]
Description=My Production Node.js API
Documentation=https://github.com/myorg/myapp
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=nodeapp
Group=nodeapp
WorkingDirectory=/opt/myapp/current
ExecStart=/usr/bin/node /opt/myapp/current/dist/server.js
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

# Security Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/opt/myapp/data /var/log/myapp
PrivateTmp=true

# Environment
Environment=NODE_ENV=production
EnvironmentFile=/opt/myapp/.env

[Install]
WantedBy=multi-user.target

Understanding critical directives

  • Type=simple: Use this for most Node.js applications. It tells systemd that the process started by ExecStart is the main process. Only use notify if your app explicitly sends readiness signals via the sd_notify protocol.
  • Restart=always: This is non-negotiable for production. It instructs systemd to restart the service regardless of whether it exited cleanly or crashed. Combine with RestartSec=5 to prevent tight restart loops that can overwhelm your database during connection storms.
  • EnvironmentFile: Keep secrets out of the unit file. Reference an external file owned by root with 600 permissions. This separates configuration from code and simplifies auditing for compliance frameworks like SOC 2.
  • After vs Requires: After=postgresql.service ensures ordering but does not fail your app if Postgres is absent. Use Requires= only if the dependency is absolutely mandatory and should take your app down with it.

How do you secure a Node.js systemd service against privilege escalation?

Security is not optional when you run Node.js in production with systemd. The default behavior of running as root or an unrestricted user violates the principle of least privilege. Systemd provides powerful sandboxing primitives that isolate your application without requiring containers.

Always create a dedicated system user with no shell access:

sudo useradd --system --no-create-home --shell /bin/false nodeapp
sudo chown -R nodeapp:nodeapp /opt/myapp

Beyond basic user isolation, apply these hardening directives inside the [Service] section:

DirectivePurposeProduction Recommendation
NoNewPrivileges=truePrevents child processes from gaining elevated privileges via setuid/setgid binariesAlways enable unless your app requires sudo internally (which it shouldn't)
ProtectSystem=strictMakes the entire filesystem read-only except explicitly allowed pathsEnable and whitelist only data directories with ReadWritePaths
PrivateTmp=trueGives the service its own isolated /tmp directoryPrevents symlink attacks and tmp file collisions between services
RestrictSUIDSGID=trueBlocks creation of SUID/SGID filesPrevents attackers from planting backdoors with elevated permissions
MemoryDenyWriteExecute=truePrevents creating memory mappings that are both writable and executableStrong defense against JIT spraying and ROP attacks; test thoroughly first

These controls significantly reduce your attack surface. In audit scenarios, demonstrating these hardening measures provides concrete evidence of defense-in-depth. For broader server security context, review our Ubuntu security hardening guide to complement application-level protections.

systemctl startFork & ExecApply SandboxActive (Running)Failed / ExitCode != 0RestartSec Wait5 seconds
Systemd enforces a controlled restart loop with configurable delays to prevent cascading failures during persistent errors.

How do you manage logs and debug a Node.js systemd service?

When you run Node.js in production with systemd, stop writing to files directly. Let systemd capture stdout and stderr, then query logs through journald. This eliminates log rotation complexity and provides structured metadata automatically.

Configure your Node.js application to output JSON to stdout. Libraries like pino or winston with console transport work well. Then use journalctl for all log operations:

# View live logs with timestamps
journalctl -u myapp.service -f --output=json-pretty

# Query logs since last boot
journalctl -u myapp.service -b 0

# Filter by time range for incident investigation
journalctl -u myapp.service --since "2026-08-18 14:00:00" --until "2026-08-18 15:00:00"

# Export logs for external analysis or compliance evidence
journalctl -u myapp.service --since today --output=export > /tmp/myapp-logs.journal

For centralized logging architectures where you need to ship logs to Elasticsearch or Loki, configure journald forwarding rather than modifying your application. This keeps your app decoupled from infrastructure concerns. Our article on structured logging best practices covers JSON schema design that pairs well with journald's metadata enrichment.

Debugging startup failures

When a service fails to start, check these in order:

  1. systemctl status myapp.service — shows exit code, recent logs, and cgroup path
  2. journalctl -u myapp.service -n 50 --no-pager — retrieves the last 50 log lines without pagination
  3. systemd-analyze verify /etc/systemd/system/myapp.service — validates unit file syntax before reloading
  4. cat /proc/$(systemctl show myapp.service -p MainPID --value)/limits — inspects actual resource limits applied to the running process

A common mistake is misconfigured WorkingDirectory or missing environment variables. Systemd does not inherit your shell environment; every variable must be explicitly declared in the unit file or EnvironmentFile.

How does systemd compare to PM2 and Docker for Node.js deployment?

Choosing the right process manager depends on your operational maturity and infrastructure constraints. Each option has legitimate use cases, but they solve different problems.

CriteriasystemdPM2Docker + Container Runtime
Native OS IntegrationFull (init system)None (userspace wrapper)Partial (requires container runtime)
Resource OverheadNegligible~30-50MB RAM for daemon~100-300MB for runtime + image layers
Clustering SupportManual (multiple units or socket activation)Built-in cluster modeOrchestrator-dependent (K8s, Swarm)
Log Managementjournald (binary, indexed)File-based with rotationContainer runtime driver (json-file, journald)
Security SandboxingExtensive (namespaces, seccomp, capabilities)MinimalStrong (isolated namespaces by default)
Compliance Audit TrailNative (tamper-evident journal)Weak (plain text files)Depends on host logging configuration
Best ForBare metal, VMs, compliance-heavy workloadsDeveloper convenience, quick prototypesMicroservices, portable deployments, K8s

In practice, I recommend systemd for single-server deployments and VMs where you want zero additional dependencies. Use PM2 only during development or for legacy apps that cannot be easily adapted. Choose containers when you need portability across environments or are building toward Kubernetes orchestration. For teams evaluating containerization, our Docker beginners guide demonstrates containerization patterns applicable to Node.js as well.

systemdZero OverheadNative SecurityAudit ReadyPM2~50MB DaemonEasy ClusteringWeak IsolationDockerHigh OverheadStrong IsolationPortable
Trade-offs between systemd, PM2, and Docker across overhead, security, and portability dimensions for production Node.js.

Deploy with Confidence

Learning to run Node.js in production with systemd gives you a reliable, auditable, and secure foundation that scales from single VPS deployments to regulated enterprise environments. Start with the unit file template above, apply security hardening directives, and validate your configuration with systemd-analyze verify before enabling. Monitor your service through journald and integrate with your observability stack for complete visibility. If you need help designing a production-grade deployment architecture or preparing for a compliance audit, reach out to discuss your infrastructure requirements.

Frequently Asked Questions

Systemd is built into Linux, requires no extra dependencies, and integrates with journalctl logging. PM2 adds overhead and complexity when native process management suffices for single-instance deployments on modern servers.

Create /etc/systemd/system/nodeapp.service with Unit, Service, and Install sections. Specify ExecStart with the full node binary path, set User to a non-root account, and enable Restart=always for automatic recovery.

Never run as root. Create a dedicated system user like nodeapp with no shell access and minimal permissions to reduce attack surface and limit damage from application vulnerabilities or dependency compromises.

Use EnvironmentFile=/etc/nodeapp/env pointing to a root-owned file with mode 600. Avoid inline Environment directives for secrets since they appear in systemctl show output and process listings visible to all users.

Yes, if Restart=always or Restart=on-failure is set in the Service section. Configure RestartSec=5 to prevent rapid restart loops and StartLimitBurst=3 with StartLimitIntervalSec=60 to stop infinite crash cycles.

Use journalctl -u nodeapp.service -f for live tailing or --since today for filtered history. Journal handles log rotation automatically, eliminating the need for external logrotate configurations or winston file transports.

Yes. Systemd sends SIGTERM by default, which Node.js handles for cleanup. Set TimeoutStopSec=30 to allow sufficient time for database connections and HTTP requests to close before SIGKILL terminates the process.

Implement SIGUSR1 handling in your app for hot reloads, then use systemctl kill --signal=SIGUSR1 nodeapp. For code deploys, use Type=notify with sd_notify to coordinate zero-downtime restarts during rolling updates.

Set /etc/systemd/system/nodeapp.service to mode 644 owned by root:root. The referenced EnvironmentFile must be 600 root-only. Run systemctl daemon-reload after any changes to apply updated unit definitions.

Add ProtectSystem=strict, ReadWritePaths=/var/lib/nodeapp, and PrivateTmp=true to the Service section. These sandboxing directives prevent the app from modifying system files or accessing other services' temporary directories.

Use Type=simple for most apps where the main process stays foregrounded. Choose Type=notify only if your app calls sd_notify when ready, enabling accurate health checks and ordered startup dependencies between services.

Add MemoryMax=512M and CPUQuota=80% to cap resources. These cgroup v2 controls prevent runaway processes from starving the host system and work without external monitoring tools or wrapper scripts.

Check journalctl -xeu nodeapp for errors. Common causes include wrong node binary path, missing environment variables, permission denied on log directories, or port conflicts with existing processes binding to the same address.

Use template units named [email protected] with %i representing the instance identifier. Enable [email protected] and [email protected] separately, each reading instance-specific config from /etc/nodeapp/%i.conf files.

No. Containers should use their runtime's init system like tini or dumb-init. Systemd is designed for host-level process management and adds unnecessary complexity inside Docker or Podman containers where PID 1 responsibilities differ.