Run Ruby in Production with systemd

Khimananda Oli 9 min read Programming and Languages
Run Ruby in Production with systemd

By Khimananda Oli | Last reviewed: August 2026

Deploying a Ruby application directly to a Linux server requires more than just starting a process; you need a supervisor that guarantees uptime and captures output reliably. To run Ruby in production with systemd, you must define a unit file that manages dependencies, enforces resource limits, and handles graceful restarts during deployments. This approach replaces fragile shell scripts and provides the observability required for serious infrastructure.

For teams managing infrastructure without containers, understanding this integration is as fundamental as hardening your Ubuntu server. While Docker and Kubernetes dominate large-scale orchestration, direct systemd management remains the gold standard for single-server deployments, edge nodes, and cost-sensitive environments common in Nepal's growing tech sector. It eliminates the overhead of container runtimes while providing enterprise-grade process supervision natively.

Systemd Supervision Architecture for Rubysystemd (PID 1)Puma Web Server(Type=notify)Sidekiq Worker(Type=simple)journaldKey Unit File Directives• WorkingDirectory=/var/www/myapp/current• EnvironmentFile=/var/www/myapp/.env.production• User=deploy / Group=deploy• Restart=on-failure / RestartSec=5s• MemoryMax=1G / CPUQuota=80%• StandardOutput=journal / StandardError=journal⚠ Never run production Ruby services as root
Systemd supervision model for Ruby applications showing process hierarchy and key configuration directives

How do you configure a systemd unit file to run Ruby in production with systemd?

The foundation of reliable Ruby service management is a correctly structured unit file. A common mistake I see in audits is copying generic templates without adjusting paths for rbenv, rvm, or system Ruby installations. Your unit file must explicitly reference the exact Ruby binary and gem environment.

Create the service unit file

Create /etc/systemd/system/myapp-web.service for your Puma or Unicorn web server. This example assumes a Capistrano-style deployment structure and rbenv installation:

[Unit]
Description=MyApp Puma Web Server
After=network.target postgresql.service redis.service
Wants=postgresql.service redis.service

[Service]
Type=notify
User=deploy
Group=deploy
WorkingDirectory=/var/www/myapp/current
EnvironmentFile=/var/www/myapp/shared/.env.production
ExecStart=/home/deploy/.rbenv/shims/bundle exec puma -C config/puma.rb
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure
RestartSec=5s
WatchdogSec=30s

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/www/myapp/shared/log /var/www/myapp/shared/tmp
PrivateTmp=true

# Resource limits
MemoryMax=1536M
CPUQuota=80%

[Install]
WantedBy=multi-user.target

Critical details often overlooked:

  • Type=notify: Puma supports sd_notify. This tells systemd the service is "active" only after Puma signals readiness, preventing premature load balancer routing.
  • EnvironmentFile: Keep secrets out of the unit file. Use a dotenv file with 0600 permissions owned by the deploy user.
  • WatchdogSec: Works with Type=notify. If Puma stops sending keepalive pings within 30 seconds, systemd considers it hung and restarts it automatically.
  • ReadWritePaths: When using ProtectSystem=strict, explicitly whitelist directories where your app writes logs, uploads, or cache files.

Configure Sidekiq workers separately

Never run web and worker processes in the same unit. Create /etc/systemd/system/myapp-sidekiq.service:

[Unit]
Description=MyApp Sidekiq Worker
After=network.target redis.service
Requires=redis.service

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/myapp/current
EnvironmentFile=/var/www/myapp/shared/.env.production
ExecStart=/home/deploy/.rbenv/shims/bundle exec sidekiq -e production -C config/sidekiq.yml
Restart=on-failure
RestartSec=10s
KillSignal=SIGTERM
TimeoutStopSec=30s

MemoryMax=2G
CPUQuota=100%

[Install]
WantedBy=multi-user.target

Sidekiq uses Type=simple because it doesn't implement sd_notify. The TimeoutStopSec=30s gives Sidekiq time to finish current jobs before SIGKILL. Adjust based on your longest expected job duration.

What are the best practices for securing Ruby services managed by systemd?

Security isn't optional when you run Ruby in production with systemd. Default configurations often grant excessive privileges. Apply defense-in-depth principles consistent with Ubuntu server security best practices.

Principle of least privilege

Always run Ruby services under a dedicated non-root user. Create one if needed:

sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/myapp deploy
sudo chown -R deploy:deploy /var/www/myapp

Never set User=root. If your app needs to bind to ports below 1024, use ambient capabilities instead:

AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

Filesystem and namespace isolation

Restrict what the service can access:

# Make most of filesystem read-only
ProtectSystem=strict
ReadWritePaths=/var/www/myapp/shared/log /var/www/myapp/shared/tmp /var/www/myapp/shared/uploads

# Hide home directories of other users
ProtectHome=true

# Prevent gaining new privileges via setuid/setgid
NoNewPrivileges=true

# Isolate /tmp
PrivateTmp=true

# Restrict system calls (advanced but recommended)
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources

In practice, start with ProtectSystem=full and tighten to strict after testing. The strict mode blocks all writes except to whitelisted paths, which catches accidental file modifications early.

Environment variable hygiene

Never embed secrets directly in unit files. They're world-readable by default. Instead:

  1. Store secrets in /var/www/myapp/shared/.env.production with mode 0600.
  2. Reference via EnvironmentFile= directive.
  3. For higher security, integrate with HashiCorp Vault or AWS Secrets Manager and fetch at startup via a wrapper script.
Ruby Service Lifecycle Under Systemdsystemctl startExecStartPre(db:migrate check)ExecStart(bundle exec puma)sd_notify READY(Active state)Process Crash(Exit ≠ 0)RestartSec=5s(Backoff delay)Auto Restart(up to StartLimitBurst)Failed State(Alert triggered)systemctl reload(Zero-downtime)ExecReload(kill -USR1)Graceful Reload(Workers respawn)Integrate with monitoring: journalctl -u myapp-web -f | forward to Loki/Prometheus
Lifecycle flow for Ruby services under systemd showing startup, failure recovery, and zero-downtime reload paths

How does systemd compare to Docker, RVM, and foreman for running Ruby?

Choosing the right process manager depends on your operational context. Here's a practical comparison based on real production deployments:

CriteriasystemdDocker/K8sRVM/foremannohup/screen
Auto-restartNative, configurable backoffKubelet/liveness probesManual or wrapper scriptsNone
Loggingjournald (structured, rotated)Container runtime + aggregatorFile-based, manual rotationStdout to file, no rotation
Resource limitscgroups v2 (MemoryMax, CPUQuota)Requests/limits in YAMLNone nativeNone
Security isolationNamespaces, seccomp, capabilitiesFull container isolationUser-level onlyNone
Boot integrationNative (WantedBy=multi-user)Requires container runtime at bootCron or init script wrapperManual
Operational overheadLow (built-in)High (cluster management)Medium (Ruby version coupling)Very high (fragile)
Best forSingle-server, edge, cost-sensitiveMicroservices, multi-team scaleLocal development onlyNever in production

In my experience helping Nepali startups optimize costs, systemd delivers 90% of Docker's reliability benefits with near-zero overhead. Reserve containers for when you need multi-service orchestration or team isolation. For a Rails monolith serving local traffic, systemd is simpler, faster, and cheaper.

How do you handle logging and monitoring for Ruby services under systemd?

When you run Ruby in production with systemd, logs flow to journald by default. This is actually ideal for observability when configured correctly. See structured logging best practices for deeper guidance.

Configure structured JSON logging

Configure Puma/Sidekiq to output JSON. In config/puma.rb:

stdout_redirect '/dev/stdout', '/dev/stderr', true

# Use json_formatter for structured output
require 'puma/log_writer'
log_formatter do |str|
  { ts: Time.now.iso8601(3), level: 'INFO', msg: str.strip }.to_json + "\n"
end

Journald preserves this structure. Query with:

# View live logs
journalctl -u myapp-web -f --output=json-pretty

# Filter by priority
journalctl -u myapp-web -p err..emerg --since "1 hour ago"

# Extract specific fields
journalctl -u myapp-web -o json | jq 'select(.MESSAGE | contains("ERROR"))'

Set up log forwarding

For centralized observability, forward journald entries to your stack. With Fluent Bit (lightweight, recommended over Fluentd for single servers):

[INPUT]
    Name            systemd
    Tag             host.ruby.*
    Read_From_Tail  On

[FILTER]
    Name            modify
    Match           host.ruby.myapp-web
    Add             app     myapp-web
    Add             env     production

[OUTPUT]
    Name            loki
    Match           host.ruby.*
    Host            loki.internal
    Port            3100
    Labels          job=systemd-ruby

Monitor service health

Expose metrics for Prometheus. Add the prometheus-client gem and configure Puma's plugin:

# config/puma.rb
plugin :metrics
metrics_url 'http://0.0.0.0:9394/metrics'

Then scrape from Prometheus and alert on puma_pool_capacity dropping below threshold. Combine with systemd's native status checks:

# Check service health programmatically
systemctl is-active myapp-web && echo "OK" || echo "CRITICAL"
Traditional (File Logs)systemd + journaldRuby Applog/production.loglogrotate (cron)tail -F + grepRuby App (JSON)journald (binary)Fluent BitLoki / Grafana✓ Auto-rotation✓ Structured queries✓ Backpressure handling✓ Metadata enrichment✗ Disk fills, lost logs, no structure✓ Reliable, queryable, observable
Logging architecture comparison: traditional file-based logging versus systemd journald with structured forwarding

How do you perform zero-downtime deployments with systemd-managed Ruby apps?

Systemd supports graceful reloads natively, which is essential for maintaining availability during deploys. Configure your unit file with ExecReload=/bin/kill -USR1 $MAINPID for Puma.

Deployment workflow

  1. Deploy new code to a release directory (Capistrano, Kamal, or custom script).
  2. Run database migrations: systemctl exec myapp-web -- bundle exec rails db:migrate.
  3. Trigger reload: systemctl reload myapp-web.
  4. Puma spawns new workers with updated code while old workers finish current requests.
  5. Old workers exit gracefully after completing in-flight work.

For Sidekiq, use phased restarts to avoid interrupting long-running jobs:

# Signal Sidekiq to stop accepting new jobs and finish current ones
systemctl kill --signal=SIGTERM myapp-sidekiq

# Wait for graceful shutdown (respects TimeoutStopSec)
systemctl wait myapp-sidekiq

# Start with new code
systemctl start myapp-sidekiq

Always test reload behavior in staging first. A misconfigured ExecReload can cause silent failures where systemd reports success but workers never pick up new code.

Next Steps for Production Ruby Services

To successfully run Ruby in production with systemd, start with the base unit file template above, apply security hardening directives, and integrate structured logging from day one. Validate every change with systemd-analyze verify /etc/systemd/system/myapp-web.service before reloading. Monitor service health through both systemd status and application metrics exposed to Prometheus.

If you're setting up Ruby services on Ubuntu servers in Nepal or globally and want to ensure your configuration meets security and compliance standards, reach out for a consultation. I help teams build audit-ready infrastructure that doesn't break under traffic spikes or compliance reviews.

Frequently Asked Questions

Create /etc/systemd/system/ruby-app.service with Unit, Service, and Install sections. Specify Type=simple, User=deploy, WorkingDirectory, and ExecStart pointing to your Ruby binary and app entrypoint. Run systemctl daemon-reload after saving.

Systemd provides native process supervision, automatic restarts, journal logging, and resource limits without extra gem dependencies. It integrates directly with OS boot sequences and avoids shell wrapper complexity that complicates debugging and signal handling.

Use the full path to bundle exec followed by your server command, like /usr/local/bin/bundle exec puma -C config/puma.rb. Never rely on PATH resolution; always specify absolute paths to both bundle and ruby binaries.

Use EnvironmentFile=/etc/ruby-app/env pointing to a root-owned file with 0600 permissions containing KEY=VALUE pairs. Avoid inline Environment directives for secrets since they appear in process listings and systemd status output.

Yes. Set ExecReload=/bin/kill -USR1 $MAINPID in your service file. This sends SIGUSR1 to Puma or Unicorn for zero-downtime reloads. Test with systemctl reload ruby-app before automating in CI pipelines.

Add Restart=on-failure and RestartSec=5 to the Service section. Systemd restarts only on non-zero exit codes, preventing restart loops from configuration errors. Combine with StartLimitIntervalSec=60 and StartLimitBurst=3 to cap retry frequency.

Create a dedicated unprivileged user like deploy or rubyapp. Never run as root. Set User=deploy and Group=deploy in the service file, ensuring this user owns the app directory and has minimal filesystem permissions.

Use journalctl -u ruby-app.service -f for live tailing or --since today for filtered history. Logs go to journald by default; avoid redirecting stdout to files since systemd handles rotation, indexing, and persistence automatically.

Yes, Puma and Falcon support it. Create a companion .socket unit listening on port 8080, then reference it in your service with Accept=false. Systemd binds the socket before starting Ruby, enabling instant failover and lazy loading.

Add MemoryMax=1G to the Service section. Systemd kills the process if RSS exceeds this threshold, preventing OOM situations. Monitor with systemctl status ruby-app which shows current memory consumption against the configured limit.

Update ExecStart paths to the new Ruby version and run systemctl daemon-reload followed by systemctl restart ruby-app. Pin versions explicitly rather than using symlinks to avoid silent breakage during system package upgrades.

Check systemctl status ruby-app for exit codes, then journalctl -u ruby-app -n 50 --no-pager for detailed errors. Common causes include wrong WorkingDirectory, missing gems, permission issues, or incorrect bundle binstub paths.

Yes. Create [email protected] using %i as instance identifier. Enable specific instances with systemctl enable [email protected]. Each instance gets isolated environment files, ports, and log streams while sharing one unit definition.

Only if your app implements sd_notify protocol via gems like systemd-notify. Otherwise stick with Type=simple. Type=notify waits for explicit readiness signals; misconfiguration causes startup timeouts and failed deployments without clear error messages.

Add systemctl restart ruby-app to your deploy:restart task. Use shared/environment files referenced by EnvironmentFile directive so deploys don't overwrite secrets. Ensure deploy user has passwordless sudo access limited to specific systemctl commands only.