
Table of Contents
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.
/etc/systemd/system/myapp.service specifying the Ruby binary path, working directory, and environment. Configure Restart=on-failure for resilience, use Type=notify for Puma/Sidekiq readiness signaling, and manage it via systemctl. Always validate configuration with systemd-analyze verify before enabling.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.
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:
- Store secrets in
/var/www/myapp/shared/.env.productionwith mode 0600. - Reference via
EnvironmentFile=directive. - For higher security, integrate with HashiCorp Vault or AWS Secrets Manager and fetch at startup via a wrapper script.
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:
| Criteria | systemd | Docker/K8s | RVM/foreman | nohup/screen |
|---|---|---|---|---|
| Auto-restart | Native, configurable backoff | Kubelet/liveness probes | Manual or wrapper scripts | None |
| Logging | journald (structured, rotated) | Container runtime + aggregator | File-based, manual rotation | Stdout to file, no rotation |
| Resource limits | cgroups v2 (MemoryMax, CPUQuota) | Requests/limits in YAML | None native | None |
| Security isolation | Namespaces, seccomp, capabilities | Full container isolation | User-level only | None |
| Boot integration | Native (WantedBy=multi-user) | Requires container runtime at boot | Cron or init script wrapper | Manual |
| Operational overhead | Low (built-in) | High (cluster management) | Medium (Ruby version coupling) | Very high (fragile) |
| Best for | Single-server, edge, cost-sensitive | Microservices, multi-team scale | Local development only | Never 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" 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
- Deploy new code to a release directory (Capistrano, Kamal, or custom script).
- Run database migrations:
systemctl exec myapp-web -- bundle exec rails db:migrate. - Trigger reload:
systemctl reload myapp-web. - Puma spawns new workers with updated code while old workers finish current requests.
- 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.