Run Scala in Production with systemd

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

By Khimananda Oli | Last reviewed: August 2026

Deploying JVM applications directly on Linux hosts remains a common requirement for teams that need low latency or cannot justify container orchestration overhead. To run Scala in production with systemd reliably, you must move beyond simple startup scripts and define a proper unit file that manages process lifecycle, resource limits, and security boundaries. This approach integrates your application into the OS init system, ensuring automatic restarts, structured logging via journald, and compliance-ready audit trails. For teams also managing data stores, this mirrors the discipline required for PostgreSQL administration essentials, where service reliability depends entirely on correct daemon configuration.

systemd (PID 1)Init & SupervisorScala App (JVM)User: scala-appEnvFile: /etc/default/appjournaldStructured Logs/opt/scala-app/app.jarReadOnlyPaths=/
Systemd supervises the Scala JVM process, injecting environment variables and capturing all stdout/stderr into the journal for centralized observability.

How do you write a production-grade systemd unit file for Scala?

The unit file is the contract between your application and the operating system. A common mistake when users first run Scala in production with systemd is treating the unit file as a mere wrapper for a shell script. In practice, you should invoke the Java binary directly to allow systemd to track the main process PID accurately. If you wrap it in bash, systemd monitors the shell, not the JVM, which breaks signal handling and resource accounting.

Core Unit File Configuration

Create the file at /etc/systemd/system/my-scala-app.service. The following configuration assumes a fat JAR deployment model, which is standard for most Scala frameworks like Akka, Pekko, or ZIO HTTP.

[Unit]
Description=My Scala Production Service
Documentation=https://internal.wiki/my-scala-app
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=scala-app
Group=scala-app
WorkingDirectory=/opt/my-scala-app

# Environment and Secrets
EnvironmentFile=-/etc/default/my-scala-app
Environment="JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64"

# Direct JVM Execution - No Shell Wrapper
ExecStart=/usr/lib/jvm/java-21-openjdk-amd64/bin/java \
  -XX:+UseZGC \
  -Xmx4g \
  -Xms4g \
  -Dconfig.file=/etc/my-scala-app/application.conf \
  -Dlogback.configurationFile=/etc/my-scala-app/logback.xml \
  -jar /opt/my-scala-app/lib/my-scala-app.jar

# Restart Policy
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=60
StartLimitBurst=3

# Resource Limits
LimitNOFILE=65536
LimitNPROC=4096
MemoryMax=4.5G
CPUQuota=200%

[Install]
WantedBy=multi-user.target

Several directives here deserve specific attention. The Type=simple setting tells systemd that the process started by ExecStart is the main service. This works perfectly for standard JVM applications because the java binary does not fork. If your application supports the sd_notify protocol (via libraries like junixsocket), switch to Type=notify to let systemd know exactly when the app is ready to accept traffic. This prevents load balancers from sending requests during warm-up.

The Restart=on-failure directive combined with StartLimitBurst creates a safety valve. If your Scala app crashes five times in sixty seconds, systemd stops trying. This prevents crash loops from filling up disks with logs or exhausting CPU. For financial or compliance-heavy systems common in Nepal's growing fintech sector, this deterministic failure behavior is preferable to an infinite retry loop that masks underlying bugs.

Managing Configuration Outside the JAR

Never bake production configuration into your artifact. Use the -Dconfig.file flag to point to an external HOCON file managed by Ansible or Terraform. This aligns with the twelve-factor app methodology and makes audits significantly easier. When an auditor asks what database credentials were active on a specific date, you can check version control or backup snapshots of /etc/my-scala-app/ rather than decompiling JARs.

What JVM flags are essential for systemd-managed Scala services?

Running inside systemd changes how the JVM interacts with the host. Container-aware JVMs (Java 10+) respect cgroup limits, but only if configured correctly. When you run Scala in production with systemd, you are running in a cgroup v2 slice, and the JVM must honor those boundaries to avoid OOM kills.

  • -XX:+UseZGC or -XX:+UseShenandoahGC: For Scala services handling concurrent requests, low-latency garbage collectors are usually superior to G1GC in 2026. ZGC keeps pause times under 1ms regardless of heap size, which simplifies SLO adherence.
  • -Xmx and -Xms parity: Set minimum and maximum heap to the same value. This prevents the JVM from dynamically resizing the heap, which causes latency spikes and complicates memory accounting within the systemd MemoryMax limit.
  • -XX:MaxMetaspaceSize=256m: Scala generates significant metaspace usage due to implicit classes, macros, and reflection. Without a cap, a classloader leak can consume all available RAM before MemoryMax triggers, potentially destabilizing other services on the host.
  • -XX:+ExitOnOutOfMemoryError: Let the JVM die cleanly on OOM so systemd can restart it. A zombie JVM process that is technically alive but functionally dead is worse than a clean restart cycle.
  • -Djava.security.egd=file:/dev/./urandom: Ensures sufficient entropy for cryptographic operations. On virtualized servers, especially in smaller VPS environments, blocking on /dev/random can cause startup timeouts exceeding systemd's DefaultTimeoutStartSec.

Avoid using -XX:+UseContainerSupport flags explicitly unless you are on an older JDK. Modern OpenJDK builds enable this by default and detect systemd cgroups automatically. You can verify detection by checking jcmd <pid> VM.system_properties | grep container on the running process.

Start: Define ResourcesSet MemoryMax in systemd unitSet -Xmx = 75% of MemoryMaxLow Latency?Use ZGC / ShenandoahHigh Throughput?Use G1GC / ParallelAlways: -XX:+ExitOnOutOfMemoryError
Decision flow for JVM tuning: always leave headroom between Xmx and systemd MemoryMax to account for non-heap memory and native threads.

How do you secure and harden a Scala systemd service?

Security is not optional when you run Scala in production with systemd. The principle of least privilege applies as rigorously here as it does in Kubernetes RBAC policies. Systemd provides powerful sandboxing primitives that restrict what your JVM can access, reducing the blast radius if your application is compromised.

DirectivePurposeRecommended Value
User= / Group=Runs process as non-root dedicated userscala-app (create via useradd -r -s /bin/false)
NoNewPrivileges=Prevents gaining privileges via setuid/setgidtrue
ProtectSystem=Makes filesystem read-only except specified pathsstrict + ReadWritePaths=/var/lib/my-scala-app
PrivateTmp=Gives service its own isolated /tmp namespacetrue
RestrictSUIDSGID=Blocks creation of SUID/SGID filestrue
SystemCallFilter=Whitelists allowed syscalls@system-service @network-io
IPAddressDeny=Network-level egress filteringany + IPAddressAllow=10.0.0.0/8 (DB subnet)

Add these directives to the [Service] section. Start permissive and tighten iteratively. Use systemd-analyze security my-scala-app.service to get an exposure score; aim for below 5.0 in production. This command highlights exactly which sandboxing features you have missed. For teams handling sensitive data, this security score becomes a measurable KPI for compliance audits.

Secret Management Without Environment Variables

While EnvironmentFile is convenient, environment variables are visible in /proc/<pid>/environ to any user who can ptrace the process. For high-security deployments, consider using LoadCredentialEncrypted= (systemd 256+) or mounting secrets via a tmpfs volume managed by HashiCorp Vault Agent. This keeps credentials out of the process environment entirely. If you must use EnvironmentFile, ensure permissions are set to 0600 owned by the service user, and never commit these files to version control.

How do you handle logging and observability with systemd?

When you run Scala in production with systemd, stdout and stderr flow directly into journald. This is actually beneficial: you get structured metadata (timestamps, PIDs, unit names) without configuring file-based log shippers. However, you must configure your Scala logging framework correctly to avoid double-formatting.

Configure Logback or Log4j2 to output plain text or JSON to console only. Disable file appenders entirely. Let journald handle persistence, rotation, and forwarding. This simplifies operations and ensures logs survive application crashes. For centralized analysis, forward journals to your stack using journalctl --output=json -f piped to a shipper, or configure systemd-journal-remote. This approach aligns with modern structured logging best practices where the transport layer is decoupled from the application.

To query logs efficiently:

# Follow live logs for your service
journalctl -u my-scala-app.service -f --no-pager

# Get logs since last boot with JSON output for parsing
journalctl -u my-scala-app.service -b -o json-pretty

# Filter by priority (errors only)
journalctl -u my-scala-app.service -p err --since "2026-08-20 10:00:00"

Set StandardOutput=journal and StandardError=journal explicitly in your unit file to make this intent clear. Avoid syslog identifier unless you have legacy rsyslog infrastructure. Journal-native logging preserves structured fields that syslog flattens.

Traditional File LoggingLogback File Appender/var/log/app.logFilebeat / Fluentd✗ Rotation config needed✗ Permission complexitySystemd Journal LoggingConsole Appenderjournald (binary)journal-remote / API✓ Auto rotation & compression✓ Structured metadata built-inVerdict: Prefer journald for bare-metal & VM deploymentsReduces operational toil and eliminates log file permission issues
Logging architecture comparison: systemd journal eliminates intermediate files and shippers, reducing failure points for Scala services on Linux.

Run Scala in Production with systemd: Operational Checklist

Successfully operating Scala on systemd requires ongoing discipline beyond initial setup. Treat your unit files as code: version them, review them, and test them in staging before production deployment. Validate every change with systemd-analyze verify to catch syntax errors and missing dependencies before they cause outages. Monitor service health using systemctl status and integrate watchdog checks if your application supports them. When scaling across multiple hosts, manage unit files through Ansible or Puppet to prevent configuration drift. This infrastructure-as-code approach ensures that your ability to run Scala in production with systemd remains consistent, auditable, and recoverable. If your team needs help designing compliant JVM deployments or auditing existing systemd configurations, reach out to discuss your infrastructure requirements.

Frequently Asked Questions

Create /etc/systemd/system/scala-app.service with Unit, Service, and Install sections. Set Type=simple, specify the Java binary path, and define ExecStart with your jar file and JVM flags.

Use the absolute path to java followed by -jar and the full path to your assembled uber-jar. Avoid shell wrappers or relative paths to ensure systemd tracks the main process PID correctly.

Use Type=simple unless your application explicitly sends sd_notify signals. Most Scala HTTP servers do not implement this protocol natively, making simple the safer default choice for production deployments.

Define MemoryMax and MemoryHigh in the Service section to enforce cgroup limits. Combine these with standard JVM heap flags like -Xmx to prevent out-of-memory kills while respecting container boundaries.

Yes, configure ExecReload=/bin/kill -HUP $MAINPID in your unit file. Your Scala application must catch SIGHUP and re-read config files without restarting the JVM process for this to work.

Use EnvironmentFile=-/etc/default/scala-app instead of inline Environment directives. This keeps secrets out of unit files, allows per-environment overrides, and prevents sensitive data from appearing in systemctl status output.

Never use root. Create a dedicated system user with no login shell and assign it via User= and Group= directives. Restrict file permissions so only this account can read application jars and configs.

Set Restart=on-failure and RestartSec=5s in the Service section. This restarts the JVM only on non-zero exit codes or signals, avoiding restart loops during planned maintenance or clean shutdowns.

They are captured by journald automatically. Query them using journalctl -u scala-app.service. Do not redirect output to files in ExecStart as this breaks log rotation and centralized logging pipelines.

Use CPUQuota=50% to restrict the service to half of one core. This uses cgroups v2 throttling and prevents runaway GC threads or infinite loops from starving other critical system processes.

Yes, systemd sends SIGTERM by default. Ensure your Scala app registers a JVM shutdown hook to close database pools and finish active requests before TimeoutStopSec expires and SIGKILL is sent.

Run systemctl status scala-app.service and journalctl -xeu scala-app.service. Check for missing environment variables, incorrect jar paths, or permission errors that typically cause immediate exit code failures.

Yes, create [email protected] and instantiate with systemctl start scala-app@instance1. Use %i in ExecStart to reference the instance name, allowing shared configuration with distinct runtime parameters per deployment.

Systemd has lower overhead and simpler debugging for bare-metal VMs. Docker adds isolation but increases complexity. Choose systemd for single-tenant servers where direct OS integration and minimal latency matter most.

Systemd cannot achieve zero-downtime deploys alone. Use blue-green deployments with two service units behind a reverse proxy, or rely on socket activation to queue requests during brief restart windows.