Run Elixir in Production with systemd

Khimananda Oli 8 min read Programming and Languages
Run Elixir in Production with systemd

By Khimananda Oli | Last reviewed: August 2026

Deploying BEAM applications on bare metal or virtual machines requires a process manager that understands modern Linux init systems. To run Elixir in production with systemd, you must configure a service unit that manages the Erlang runtime's unique signal handling, environment isolation, and log aggregation. This guide provides the exact configuration needed for a secure, observable, and resilient deployment on Ubuntu or RHEL-based servers.

systemdPID 1 / InitEnvironmentFile/etc/myapp/envElixir Releasebin/myapp start(BEAM VM)journaldStructured Logs
Systemd orchestrates the Elixir release by injecting environment variables before start and capturing stdout/stderr directly into the journal.

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

The foundation of any reliable BEAM deployment is a correctly structured unit file. Unlike simple scripts, Elixir releases built with mix release support systemd's native notification protocol. This allows the VM to signal readiness only after all applications have started and passed their own internal health checks. For teams managing infrastructure manually or via tools like Ansible, understanding this integration is critical. If you are also managing databases on the same host, refer to our guide on PostgreSQL administration essentials to ensure your data tier matches your application tier's reliability.

Creating the Service Unit

Create the file /etc/systemd/system/myapp.service. The following configuration uses Type=notify, which is superior to Type=simple for Elixir because it prevents systemd from marking the service as "active" until the BEAM VM explicitly sends the READY=1 signal via sd_notify.

[Unit]
Description=MyApp Elixir Production Service
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=notify
User=elixir
Group=elixir
WorkingDirectory=/opt/myapp/current
EnvironmentFile=/etc/myapp/environment
ExecStart=/opt/myapp/current/bin/myapp start
ExecStop=/opt/myapp/current/bin/myapp stop
Restart=on-failure
RestartSec=5
WatchdogSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

# Security Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/var /tmp
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Key Configuration Directives Explained

  • Type=notify: Requires the :systemd_notify library in your Elixir project. Without it, systemd will wait indefinitely and eventually kill the process. If you cannot add this dependency, fall back to Type=exec but accept reduced readiness accuracy.
  • WatchdogSec=30: Enables automatic restart if the application hangs. Your Elixir app must periodically send WATCHDOG=1 notifications. This catches deadlocks that standard crash detection misses.
  • EnvironmentFile: Never hardcode secrets in the unit file. Use a root-owned, 600-permission file for DATABASE_URL, SECRET_KEY_BASE, and API keys.
  • ReadWritePaths: When using ProtectSystem=strict, the entire filesystem becomes read-only except for paths listed here. This is essential for compliance frameworks like SOC 2 where write-access minimization is required.

What are the best practices for securing Elixir services on Linux?

Security is not an afterthought; it is a configuration parameter. When you run Elixir in production with systemd, you inherit powerful sandboxing capabilities that isolate your application from the rest of the host. In my experience auditing deployments for Nepal-based fintechs and global SaaS platforms, most vulnerabilities stem from over-privileged service accounts rather than application code flaws.

Principle of Least Privilege

Never run your release as root. Create a dedicated system user with no shell access:

sudo useradd -r -s /bin/false -U -d /opt/myapp elixir
sudo mkdir -p /opt/myapp/var /opt/myapp/current
sudo chown -R elixir:elixir /opt/myapp

Filesystem and Network Sandboxing

Modern systemd versions (v240+) support aggressive isolation. Add these directives to lock down the runtime environment:

DirectivePurposeImpact on Elixir
ProtectKernel=yesBlocks access to kernel logs and modulesPrevents information leakage; safe for standard web apps
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIXLimits socket typesBlocks raw sockets and netlink; verify if using custom protocols
MemoryDenyWriteExecute=yesPrevents JIT code injectionWarning: May break some NIFs; test thoroughly first
RestrictRealtime=yesBlocks realtime schedulingSafe for web/API servers; avoid for low-latency audio processing

For deeper insights into securing the underlying operating system before deploying your application, review our Ubuntu security hardening guide. These OS-level controls complement systemd's per-service sandboxing.

systemctl startLoad EnvironmentFile(Secrets Injected)ExecStart (BEAM)Drop Root PrivsApp InitializationWait for READY=1Active (Running)Watchdog Loop ActiveWatchdog Timeout?Auto-Restart Triggered
Secure startup flow: environment injection, privilege dropping, readiness signaling, and continuous watchdog monitoring form a defense-in-depth lifecycle.

How do you manage logs and observability for Elixir systemd services?

When you run Elixir in production with systemd, you should abandon file-based logging in favor of the journal. Systemd captures stdout and stderr automatically, tagging each line with the unit name, PID, and timestamp. This integrates natively with observability stacks and eliminates the need for logrotate configurations.

Configuring Logger for Journal Integration

In your config/runtime.exs, configure the logger to output plain text or JSON to stdout. Avoid file backends in production:

config :logger, :default_handler,
  level: :info,
  formatter: {Logger.JSONFormatter, %{metadata: [:request_id, :trace_id]}}

config :logger, backends: [:console]

Querying Logs Effectively

Use journalctl with structured filters instead of grep:

  • journalctl -u myapp.service --since "1 hour ago" — Recent logs only
  • journalctl -u myapp.service -p err — Errors and above only
  • journalctl -u myapp.service -o json-pretty — Full metadata for debugging
  • journalctl -u myapp.service _PID=12345 — Filter by specific BEAM process

For centralized aggregation, forward journal entries to Loki or Elasticsearch using systemd-journal-gatewayd or Fluent Bit. Our article on structured logging best practices covers schema design for high-volume BEAM applications.

How does systemd compare to Docker or Kubernetes for running Elixir?

Choosing between systemd, containers, and orchestration depends on operational complexity, team size, and compliance requirements. There is no universal best option—only trade-offs.

Criteriasystemd (Bare Metal/VM)Docker ComposeKubernetes (EKS/GKE)
Startup Latency<100ms (direct exec)200–500ms (container overhead)1–5s (scheduler + pull)
Resource OverheadNegligibleLow (~50MB base)High (kubelet, etcd, CNI)
Secret ManagementEnvironmentFile / Vault AgentDocker Secrets / Env FilesK8s Secrets / External Secrets Operator
Rolling DeploysManual / Custom ScriptsLimited (recreate only)Native (zero-downtime)
Audit Trailjournald (immutable)Container logs (ephemeral)Audit API + Pod logs
Best ForSingle-node, compliance, edgeLocal dev, small teamsMulti-region, auto-scaling

For many Nepal-based startups and SMEs, systemd on a well-provisioned VPS offers the best balance of cost, performance, and simplicity. Kubernetes introduces significant operational tax that only pays off at scale. If you are evaluating container orchestration, our comparison of Amazon EKS provides real-world sizing guidance.

systemdOverhead: ~0%Startup: <100msDirect BEAM ExecJournal NativeAudit ReadyDockerOverhead: LowStartup: 300msContainer RuntimeImage LayersKubernetesOverhead: HighStartup: 2–5sScheduler TaxMinimalModerateSignificant
Resource and latency comparison: systemd provides near-zero overhead for Elixir workloads, while Kubernetes adds scheduler tax that only justifies itself at scale.

How do you handle deployments and zero-downtime restarts with systemd?

Systemd alone does not provide zero-downtime deployments, but it enables them through socket activation and graceful shutdown handling. Elixir releases support SIGTERM natively, allowing in-flight requests to complete before exit.

Graceful Shutdown Configuration

Add these directives to ensure clean stops during deploys:

TimeoutStopSec=30
KillMode=mixed
KillSignal=SIGTERM
FinalKillSignal=SIGKILL

KillMode=mixed sends SIGTERM only to the main BEAM process, allowing child processes (like NIFs or ports) to receive the signal through the VM's supervision tree. After 30 seconds, systemd escalates to SIGKILL only if necessary.

Blue-Green Deployment Pattern

For true zero-downtime on a single node, use a blue-green approach with systemd:

  1. Deploy new release to /opt/myapp/green
  2. Start myapp-green.service on a different port
  3. Run health checks against the green instance
  4. Update Nginx/HAProxy upstream to point to green
  5. Stop myapp-blue.service gracefully
  6. Promote green to current symlink

This pattern avoids complex orchestration while delivering production-grade reliability. It pairs well with the blue-green deployment strategies used in containerized environments, adapted for bare-metal constraints.

Production Readiness Checklist

Before marking your Elixir service as production-ready, verify these items:

  • Unit file uses Type=notify with working sd_notify integration
  • Service runs as non-root user with NoNewPrivileges=true
  • Secrets loaded via EnvironmentFile, not hardcoded
  • WatchdogSec configured and tested with simulated hangs
  • Logs flowing to journald with structured format
  • Graceful shutdown tested under load with systemctl stop
  • Filesystem write paths explicitly whitelisted
  • Monitoring alerts configured for service failures and watchdog timeouts

To run Elixir in production with systemd successfully, treat the unit file as infrastructure code: version it, review it, and test it. The BEAM VM is remarkably resilient, but it depends entirely on the OS layer to handle failures it cannot see. If you need help designing a compliant, observable deployment pipeline for your Elixir application, get in touch to discuss your architecture.

Frequently Asked Questions

Create /etc/systemd/system/myapp.service with Unit, Service, and Install sections. Set Type=notify, User=elixir, WorkingDirectory to your release path, and ExecStart pointing to your bin/start script. Enable WatchdogSec if using OTP application signals for proper health monitoring integration.

Use the absolute path to your release start script, typically /opt/myapp/bin/myapp start. Avoid using mix or elixir commands directly in production. The release binary handles boot scripts, environment loading, and VM arguments correctly without requiring Mix at runtime.

Type=notify waits for sd_notify readiness signals from your OTP application before marking the service active. This prevents load balancers from routing traffic before your app finishes booting, database migrations complete, and all supervised processes are fully initialized and ready.

Use EnvironmentFile=/etc/myapp/env instead of inline Environment directives. Set file permissions to 600 owned by the service user. This keeps secrets out of process listings and unit files while allowing different configurations per environment without modifying the service definition itself.

Yes. Set Restart=on-failure and RestartSec=5 in your service file. Systemd tracks exit codes and only restarts on non-zero exits or signals. Combine with StartLimitIntervalSec and StartLimitBurst to prevent restart loops from consuming resources during persistent failures.

Configure Logger backends to write to standard output and error streams. Systemd captures these automatically into journald. Use journalctl -u myapp.service for filtering. Avoid file-based logging in production since journald handles rotation, indexing, and structured metadata natively without additional tooling.

Create a dedicated system user like elixir or myapp with no login shell. Never run as root. Set User= and Group= in the service file. This limits blast radius during compromises and follows principle of least privilege for production workloads.

Use systemctl reload with a custom ExecReload script that triggers hot code upgrades via RPC or releases. Alternatively, deploy new releases to versioned directories and atomically symlink before restarting. Combine with Type=notify to ensure systemd only marks the service ready after upgrade completes successfully.

Yes. Systemd sends SIGTERM first, which OTP translates to application stop callbacks. Set TimeoutStopSec=30 to allow sufficient time for connection draining and cleanup. If exceeded, systemd sends SIGKILL. Configure your supervision tree shutdown strategy to respect this window properly.

Check journalctl -xeu myapp.service for recent logs and exit codes. Use systemctl status for current state and last failure reason. Verify file permissions, environment variables, and working directory existence. Test ExecStart manually as the service user to isolate configuration versus application issues.

Yes. Systemd timers provide better logging, dependency management, and randomized delays to prevent thundering herd problems. Define timer units alongside your service. They integrate with journald, support monotonic and realtime schedules, and can trigger specific release commands without external scheduler dependencies.

Use MemoryMax, CPUQuota, and IOWeight directives in the Service section. These leverage cgroups v2 to enforce hard limits and fair scheduling. Prevents runaway processes from affecting other services. Monitor actual usage with systemd-cgtop to tune values based on production telemetry data.

Missing read access to release directories, unwritable log or tmp paths, and incorrect socket ownership. Ensure WorkingDirectory and all referenced paths are owned by the service user. Check SELinux or AppArmor denials in audit logs. Test with sudo -u myapp before enabling the service.

Implement sd_notify watchdog pings in your OTP application. Set WatchdogSec in the unit file. Systemd marks the service failed if pings stop, triggering automatic restarts. External load balancers can query systemctl is-active or use HTTP health endpoints exposed by your application for routing decisions.

Depends on infrastructure. Systemd offers lower overhead and tighter OS integration for bare metal or VMs. Docker provides isolation and portability across environments. Many teams combine both, running containers managed by systemd for consistent lifecycle management, logging, and restart policies across mixed deployment targets.