Run Java in Production with systemd

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

By Khimananda Oli | Last reviewed: August 2026

Running a Java application as a background process via nohup or raw shell scripts is a liability in any serious environment. To reliably run Java in production with systemd, you must leverage the init system’s native dependency management, resource isolation, and automatic restart capabilities rather than fighting against them. This approach transforms a fragile JAR execution into a managed, observable system service that integrates seamlessly with modern observability stacks like those discussed in my guide on the four golden signals of monitoring. The following configuration patterns reflect current best practices for JDK 21+ and systemd v255+, ensuring your services survive reboots, crashes, and memory pressure without manual intervention.

systemd PID 1Service ManagerJava ApplicationJDK 21 + Spring BootMemoryMax=2G / CPUQuota=80%Restart=on-failure (5s delay)journaldStructured Logsjournalctl -u app
Systemd manages the Java process lifecycle, enforces cgroup resource limits, and captures stdout/stderr directly into the binary journal for reliable observability.

How do you write a secure systemd unit file to run Java in production with systemd?

The foundation of running Java reliably is a correctly structured unit file. Many engineers copy-paste generic templates that lack security boundaries or proper environment handling. A production-grade unit must explicitly define the execution user, working directory, and JVM parameters without relying on shell expansion or root privileges. Below is a battle-tested template for a Spring Boot or standard JAR application running on Ubuntu 24.04 or RHEL 9+.

[Unit]
Description=Production Java API Service
Documentation=https://internal.wiki/java-api-runbook
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=javaapp
Group=javaapp
WorkingDirectory=/opt/javaapp

# Environment configuration
EnvironmentFile=-/opt/javaapp/conf/app.env
Environment="JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64"
Environment="SPRING_PROFILES_ACTIVE=production"

# Execution command with explicit GC and memory settings
ExecStart=/usr/lib/jvm/java-21-openjdk-amd64/bin/java \
  -Xms1g -Xmx2g \
  -XX:+UseZGC \
  -XX:+ExitOnOutOfMemoryError \
  -Djava.security.egd=file:/dev/./urandom \
  -jar /opt/javaapp/lib/api-service.jar

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

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/javaapp/data /var/log/javaapp
PrivateTmp=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true

# Resource limits via cgroups v2
MemoryMax=2G
CPUQuota=80%

# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=javaapi

[Install]
WantedBy=multi-user.target

Several directives here deserve specific attention. Type=simple is correct for most Java applications because the JVM does not fork; it runs as a single foreground process. Using Type=forking requires a PIDFile and adds unnecessary complexity. The -XX:+ExitOnOutOfMemoryError flag is critical: by default, the JVM may continue running in a degraded state after an OOM event, causing systemd to believe the service is healthy while requests fail silently. Forcing an exit ensures systemd’s restart logic actually triggers.

Security sandboxing directives like ProtectSystem=strict and NoNewPrivileges=true are non-negotiable in 2026. If your application is compromised, these prevent lateral movement and privilege escalation. I have audited too many environments where Java services ran as root with full filesystem access—a single deserialization vulnerability could own the entire host. Always pair this setup with proper Linux file permissions so the service account can only read its binaries and write to designated data directories.

What JVM flags are essential when configuring systemd for Java applications?

Systemd handles process supervision, but the JVM itself requires tuning to behave predictably under cgroup constraints. Modern Linux distributions use cgroups v2, and older JVM versions historically ignored container-aware memory limits, leading to OOM kills despite setting -Xmx. With JDK 21+, container awareness is default, but you should still be explicit.

  • -Xms and -Xmx parity: Set initial and max heap to the same value (e.g., -Xms2g -Xmx2g) to avoid runtime resizing overhead and ensure predictable memory reservation within the cgroup limit.
  • -XX:+UseZGC or -XX:+UseShenandoahGC: Low-latency garbage collectors are now production-default for services requiring sub-millisecond pause times. ZGC scales well from 2GB to multi-TB heaps.
  • -XX:MaxMetaspaceSize=256m: Prevents metaspace from growing unbounded, which can cause OOM kills outside the heap limit. Monitor this if you dynamically load classes.
  • -Djava.net.preferIPv4Stack=true: Avoids dual-stack binding issues in environments where IPv6 is disabled at the kernel level but not in Java defaults.
  • -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/javaapp/data/heapdumps/: Captures forensic evidence before the process exits. Ensure the path is writable and has sufficient disk space.

A common mistake is setting -Xmx equal to the systemd MemoryMax value. The JVM uses memory beyond the heap: metaspace, thread stacks, direct buffers, JIT code cache, and GC overhead. As a rule, set MemoryMax to at least 1.5× your -Xmx value. If your heap is 2GB, allocate 3–4GB to the cgroup. Otherwise, the kernel will kill the process even though the JVM believes it has headroom.

Java Process ExitsExit Code CheckClean Exit (0)No RestartFailure (non-zero)Trigger Restart LogicWait RestartSec=5sExecStart Re-launched
Systemd evaluates the exit code to determine restart behavior. Clean exits stop the service; failures trigger a delayed restart bounded by StartLimitBurst to prevent crash loops.

How does systemd handle Java logging and observability compared to external tools?

When you set StandardOutput=journal, systemd captures everything the JVM writes to stdout and stderr directly into the binary journal. This eliminates the need for wrapper scripts that redirect output to files, manage rotation, and risk losing logs during crashes. You query logs with journalctl -u javaapi --since "1 hour ago" and get structured metadata including timestamps, PIDs, and syslog identifiers automatically.

However, journal integration alone is insufficient for production observability. Java applications should emit structured JSON logs rather than plain text. Combine Logback or Log4j2 JSON appenders with systemd’s journal to get searchable, parseable output. When forwarding to centralized systems like Loki or Elasticsearch, configure systemd-journal-remote or a lightweight shipper. My article on structured logging best practices covers the schema design that makes this pipeline effective.

A subtle but important detail: never set StandardOutput=append:/var/log/app.log in production. File-based output bypasses journald’s rate limiting, loses metadata, and creates rotation headaches. It also means systemctl status won’t show recent log lines, making incident response slower. The journal is the canonical source; treat files as derived artifacts only when compliance mandates them.

What are the trade-offs between systemd-managed Java and containerized deployments?

In 2026, many teams default to containers, but bare-metal or VM-based systemd deployments remain valid for specific workloads. Understanding when to choose each prevents over-engineering.

Criteriasystemd DirectContainer (Docker/Podman)
Startup latency~200ms (JVM only)+300–800ms (runtime + layers)
Memory overheadJVM baseline only+50–150MB runtime overhead
Security isolationcgroups + namespace (manual)Full namespace + seccomp (default)
Dependency managementHost JDK + system libsBundled in image, immutable
Operational complexityLow (single host)Higher (registry, orchestration)
Best fitSingle-instance apps, edge, low-latencyMicroservices, scaling, CI/CD pipelines

I recommend systemd-direct for monolithic applications on dedicated VMs, edge deployments in Nepal where bandwidth constrains image pulls, or latency-sensitive services where container runtime overhead matters. Choose containers when you need horizontal scaling, immutable deployments, or multi-team ownership. Both approaches can coexist; the key is intentional selection based on operational reality, not trend-following. For teams adopting Kubernetes, my guide on Kubernetes resource limits and requests translates these same JVM tuning principles to pod specs.

systemd DirectJava Process (PID)cgroups v2Host KernelDirect Filesystem AccessContainer RuntimeJava Process (PID ns)NamespacesOverlay FSImage Layers + Volumes
Side-by-side comparison: systemd direct exposes the host kernel and filesystem with cgroup limits, while containers add namespace isolation and overlay filesystems at the cost of runtime overhead.

Run Java in Production with systemd: Final Checklist and Next Steps

Getting Java right under systemd requires attention to details that generic tutorials skip. Verify your setup against this checklist before promoting to production:

  1. Dedicated service account with no shell access and minimal filesystem permissions.
  2. Explicit -Xms/-Xmx parity and MemoryMax set to 1.5× heap size.
  3. -XX:+ExitOnOutOfMemoryError enabled to trigger systemd restarts on OOM.
  4. StandardOutput=journal with structured JSON logging from the application.
  5. Security sandboxing directives active (NoNewPrivileges, ProtectSystem=strict).
  6. Restart=on-failure with StartLimitBurst to prevent infinite crash loops.
  7. Health check endpoint monitored externally, not just process liveness.

If your team manages multiple Java services across hosts, consider templating unit files with Ansible or Terraform to enforce consistency. Audit your configurations quarterly against CVE disclosures for both systemd and the JDK version in use. When you are ready to harden the underlying host or integrate deeper observability, review my guides on Ubuntu security hardening and Prometheus and Grafana full monitoring stack to complete the production readiness picture. Reliable Java operations start with treating systemd as a first-class engineering concern, not an afterthought.

Frequently Asked Questions

Create /etc/systemd/system/myapp.service with Type=simple, User=javauser, ExecStart pointing to your java binary and jar path, then run systemctl daemon-reload and systemctl enable myapp to register it.

Systemd provides automatic restarts, logging integration, resource limits, and dependency management that nohup lacks entirely.

Use Type=simple for standard Java applications since the JVM process stays in the foreground and does not fork or daemonize itself.

Add Environment="JAVA_OPTS=-Xmx2g -Xms1g" under the Service section or use MemoryMax=3G to enforce hard cgroup memory limits at the OS level.

Yes, set Restart=on-failure and RestartSec=5 in the Service section to trigger automatic recovery after non-zero exit codes with a five-second delay between attempts.

Use journalctl -u myapp.service -f to stream live logs or journalctl -u myapp.service --since today to review historical output captured by journald.

Never run as root; specify User=javauser and Group=javauser to isolate the process and reduce security exposure from application vulnerabilities.

Use EnvironmentFile=/etc/myapp/env with 0600 permissions instead of inline Environment directives to keep secrets out of world-readable unit files and process listings.

Systemd sends SIGTERM allowing graceful shutdown hooks to execute, then waits TimeoutStopSec (default 90s) before sending SIGKILL if the JVM has not exited.

Avoid PID files with systemd; use Type=simple and let systemd track the main process directly rather than relying on stale or mismanaged PID file paths.

Yes, set CPUQuota=80% in the Service section to cap CPU consumption using cgroups, preventing the JVM from starving other system processes.

Define ExecReload=/bin/kill -HUP $MAINPID if your app supports SIGHUP, otherwise you must use systemctl restart since most Java apps lack native reload signals.

Check file ownership on the jar, log directories, and temp folders match the User and Group specified in the unit file, and verify SELinux or AppArmor policies allow access.

Add After=network-online.target postgresql.service and Wants=network-online.target to guarantee dependencies are active before systemd launches your Java application.

Yes, systemd manages any Java version including OpenJDK 21 and 25 as long as the ExecStart path points to a valid java binary and the process runs in foreground mode.