Run Kotlin in Production with systemd

Khimananda Oli 7 min read Programming and Languages
Run Kotlin in Production with systemd

By Khimananda Oli | Last reviewed: August 2026

Deploying JVM applications directly on Linux requires more than just executing a JAR file; you need process supervision, automatic restarts, and integrated logging to maintain reliability. When you run Kotlin in production with systemd, you gain native OS-level lifecycle management without the overhead of container orchestration for simpler workloads. This approach integrates your application into the host's init system, ensuring it survives reboots, captures structured logs via journald, and respects resource limits defined in your infrastructure code.

Before configuring the service itself, verify your foundation matches the standards outlined in our initial Ubuntu server setup guide. A hardened base image with correct user permissions is prerequisite to running any production workload securely. Skipping this step often leads to services running as root or failing due to missing dependencies, which defeats the purpose of using systemd for managed execution.

systemdPID 1 SupervisorKotlin App (JVM)java -jar app.jarCgroup LimitsMemoryMax=2G | CPUQuota=80%journaldStructured Logsstdout / stderr capture
Systemd supervises the Kotlin JVM process, enforces cgroup resource limits, and routes output to journald for centralized logging.

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

The unit file is the single source of truth for how your application behaves under OS supervision. For Kotlin applications compiled to fat JARs via Gradle or Maven, you must explicitly define the Java runtime path, environment variables, and restart policies. Avoid using shell scripts as wrappers; let systemd handle process management directly to preserve signal propagation and exit code accuracy.

Essential unit file directives

Create the file /etc/systemd/system/kotlin-api.service with the following configuration. This template assumes OpenJDK 21 and a dedicated service user named kotlinapp.

[Unit]
Description=Kotlin Production API Service
Documentation=https://khimananda.com/blog/run-kotlin-in-production-with-systemd
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=kotlinapp
Group=kotlinapp
WorkingDirectory=/opt/kotlin-api
EnvironmentFile=/etc/kotlin-api/env.conf
ExecStart=/usr/bin/java \
    -XX:+UseZGC \
    -Xmx2G \
    -Xms2G \
    -Dfile.encoding=UTF-8 \
    -jar /opt/kotlin-api/app.jar
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=kotlin-api

# Security Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/kotlin-api/logs /tmp
PrivateTmp=true

[Install]
WantedBy=multi-user.target
  • Type=simple: Use this for most Kotlin web servers (Ktor, Spring Boot) that block the main thread. Only use Type=notify if your application explicitly sends sd_notify readiness signals.
  • EnvironmentFile: Never hardcode secrets in the unit file. Store database URLs, API keys, and JWT secrets in /etc/kotlin-api/env.conf with permissions set to 0600 owned by root.
  • Restart=on-failure: Automatically restarts only when the process exits with a non-zero code or times out. This prevents restart loops during intentional maintenance stops.
  • SyslogIdentifier: Tags all log entries with this identifier, making journalctl -u kotlin-api queries precise and filterable.

What JVM flags optimize Kotlin applications managed by systemd?

Running Kotlin in production with systemd means the JVM operates within cgroup boundaries enforced by the kernel. Default JVM ergonomics sometimes misread container or cgroup limits, leading to OOM kills even when heap usage appears safe. You must align GC strategy and memory allocation with systemd's resource controls.

For Kotlin workloads on modern Linux kernels (5.x+), ZGC or Shenandoah provide low-latency garbage collection that respects soft memory limits better than G1GC in constrained environments. Always set -Xmx and -Xms to identical values to prevent dynamic heap resizing overhead during traffic spikes. If systemd sets MemoryMax=2G, configure -Xmx to approximately 75% of that limit (e.g., 1536M) to reserve headroom for metaspace, thread stacks, and direct buffers.

Enable container awareness explicitly if running on older JDK versions: -XX:+UseContainerSupport. On JDK 21+, this is default, but verifying with jcmd <pid> VM.system_properties | grep Container confirms the JVM sees the cgroup limits correctly. Without this, the JVM may allocate heap based on host RAM rather than the systemd-enforced ceiling, causing immediate termination.

Edit Unit File/etc/systemd/system/systemctl daemon-reloadParse new configsystemctl restartSIGTERM → Stop → StartGraceful ShutdownShutdownHook runsHealth Check PassReady for traffic
Correct update sequence: edit unit file, reload daemon configuration, then restart service to apply changes safely without orphaned processes.

How does systemd compare to Docker for deploying Kotlin applications?

Choosing between native systemd units and containerized deployments depends on operational complexity, compliance requirements, and team expertise. Both approaches can run Kotlin in production with systemd effectively, but they serve different architectural needs. The table below reflects real-world trade-offs observed across multiple production environments in 2026.

CriteriaNative systemd UnitDocker + systemd
Startup Latency<200ms (direct JVM)+300–800ms (container runtime)
Resource OverheadNegligible (kernel cgroups only)~50–150MB (containerd + overlay fs)
Log IntegrationNative journald, zero configRequires driver mapping or sidecar
Security IsolationCgroups + namespaces (manual)Full namespace isolation (default)
Dependency ManagementHost JDK required, version drift riskBundled in image, immutable
Compliance AuditingDirect host evidence, simpler SOC 2Additional container runtime audit scope
Best ForSingle-server, low-latency, regulatedMulti-node, microservices, CI parity

For teams managing compliance frameworks like ISO 27001 or SOC 2, native systemd units reduce the audit surface area significantly. There is no container runtime to patch, no image registry to secure, and no orchestrator to configure. However, if your team already standardizes on containers for local development parity, wrapping the container in a systemd unit (using podman generate systemd or similar) preserves lifecycle benefits while maintaining artifact consistency. Refer to our Docker fundamentals guide if you need to evaluate containerization first.

How do you manage logs and monitor Kotlin services under systemd?

When you run Kotlin in production with systemd, all stdout and stderr streams flow into journald automatically. This eliminates the need for separate log shippers or file-based rotation configurations in most cases. Structured logging from Kotlin frameworks like Ktor or Logback integrates seamlessly when configured to output JSON to console rather than files.

Querying and persisting logs

  1. Live tailing: Use journalctl -u kotlin-api -f to follow logs in real-time during deployments or incident response.
  2. Time-bounded queries: Filter by timestamp with journalctl -u kotlin-api --since "2026-08-19 10:00:00" --until "2026-08-19 11:00:00" for post-mortem analysis.
  3. Persistent storage: Ensure /var/log/journal exists and Storage=persistent is set in /etc/systemd/journald.conf. Without this, logs vanish on reboot.
  4. Forwarding: For centralized observability, configure journald to forward to remote collectors via ForwardToSyslog=yes or use native OpenTelemetry exporters in your Kotlin app instead of relying solely on log aggregation.

Monitoring should extend beyond logs. Expose Prometheus metrics from your Kotlin application and scrape them independently of systemd. Our Prometheus metrics guide covers endpoint instrumentation patterns that complement systemd's process-level supervision. Systemd tells you if the process is alive; application metrics tell you how well it is serving requests.

Native systemdJVM ProcessDirect cgroup bindingStartup: ~150msAudit Surface: LowLogs: Native journaldDocker + systemdContainer RuntimeNamespace isolation layerStartup: ~600msAudit Surface: HighLogs: Driver mapping needed
Native systemd offers lower latency and simpler compliance auditing, while Docker adds isolation at the cost of operational overhead and startup delay.

Run Kotlin in Production with systemd: Final Checklist

Successfully operating Kotlin services natively on Linux demands discipline in configuration, security, and observability. Before marking your deployment complete, verify these items:

  • Unit file uses Type=simple unless your app implements sd_notify protocol explicitly.
  • JVM heap is set to 75% of systemd MemoryMax to prevent OOM kills from non-heap allocations.
  • Service runs as a dedicated unprivileged user with NoNewPrivileges=true and ProtectSystem=strict.
  • Secrets reside in an EnvironmentFile with 0600 permissions, never inline in the unit.
  • Journald persistence is enabled and log retention policy matches your compliance requirements.
  • Graceful shutdown hooks are tested by sending SIGTERM manually before relying on automated restarts.

If your infrastructure spans multiple regions or requires complex scaling policies beyond what a single host can provide, consider whether Kubernetes better serves your long-term architecture. But for focused, compliant, low-overhead deployments, mastering how to run Kotlin in production with systemd remains a valuable skill that reduces operational toil and improves audit readiness. Need help designing your deployment strategy or hardening existing services? Get in touch to discuss your specific requirements.

Frequently Asked Questions

Create /etc/systemd/system/kotlin-app.service with Unit, Service, and Install sections. Set Type=simple, specify the java binary path, and define ExecStart pointing to your compiled JAR file location.

Use the absolute path to java followed by -jar and the absolute path to your fat JAR. Never use relative paths or shell aliases in systemd unit files as they fail silently during boot.

Pass -Xmx and -Xms flags directly in the ExecStart line after the java command. Alternatively, use systemd MemoryMax= directives to enforce hard cgroup limits that prevent the process from exceeding allocated RAM.

Check journalctl -u kotlin-app.service for errors. Common causes include missing environment variables, incorrect file permissions on the JAR, wrong Java version in PATH, or insufficient heap memory allocation causing immediate OOM crashes.

No. Always specify User= and Group= directives to run as a dedicated unprivileged account. Running as root exposes the entire system if the application is compromised or contains deserialization vulnerabilities common in JVM ecosystems.

Use Environment=KEY=value lines in the Service section or reference an external file with EnvironmentFile=/etc/kotlin-app/env. External files are preferred for secrets since they can have restricted permissions separate from the unit file.

Set Restart=on-failure with RestartSec=5s. This automatically restarts the JVM after crashes but prevents rapid restart loops. Combine with StartLimitIntervalSec=60 and StartLimitBurst=3 to stop restarting after repeated failures within one minute.

Set TimeoutStopSec=30s and ensure your Kotlin app handles SIGTERM properly. Systemd sends SIGTERM first, waits the timeout period, then sends SIGKILL. Configure coroutine scopes and database pools to close cleanly on shutdown signals.

Yes. Standard output and error streams are captured by journald automatically. Access logs via journalctl -u kotlin-app.service. For structured logging, configure logback or log4j2 to write JSON to stdout rather than managing separate log files.

Implement SIGHUP handling in your Kotlin application to reload configs dynamically. Then set ExecReload=/bin/kill -HUP $MAINPID in the unit file. Run systemctl reload kotlin-app to trigger the signal without downtime or JVM restart.

Target JDK 21 LTS or newer for production Kotlin deployments. Specify the full path like /usr/lib/jvm/java-21-openjdk/bin/java in ExecStart to avoid relying on system alternatives that may change during package updates.

Store secrets in /etc/kotlin-app/secrets.env with 600 permissions owned by the service user. Reference it via EnvironmentFile=-/etc/kotlin-app/secrets.env. The dash prefix prevents startup failure if the file is temporarily missing during deployment.

Use Type=notify with sd_notify() integration or implement watchdog ping via WatchdogSec=30s. Your Kotlin app must periodically call sd_notify(WATCHDOG=1) to signal liveness. Systemd restarts the service automatically if notifications stop arriving within the interval.

Set CPUQuota=80% to cap usage at 80 percent of one core, or use AllowedCPUs= to pin specific cores. These cgroup v2 controls prevent runaway garbage collection or infinite loops from starving other critical system processes.

Simple assumes ready when the process forks. Notify waits for explicit sd_notify(READY=1) from your Kotlin app before marking active. Use notify for accurate dependency ordering and health monitoring in complex microservice deployments requiring precise startup sequencing.