spring-bootlinuxsystemddevopsjavadeploymentΒ·9 min read

How to Run a Spring Boot App as a Linux Service (and How Small a Box It Needs)

Write a systemd unit with an absolute path to java, a non-root User, Restart=always β€” and SuccessExitStatus=143, which most unit files omit. Spring Boot exits 143 on SIGTERM, so without that line systemd records every normal stop as a failure. Then check the file with systemd-analyze verify, because a typo in a unit is silently ignored rather than reported.

You have a jar on a server. java -jar app.jar & works, right up until you close the terminal and it does not. This article is the unit file that fixes that properly, the one directive most versions of it are missing, and β€” because the two questions always arrive together β€” measured answers to how much memory the thing actually needs.

Everything below was run: a real systemd as PID 1, loading the unit, restarting the service after a kill -9, and stopping it cleanly with and without the line that decides whether that stop is recorded as a success.

Why the process dies when you log out

java -jar app.jar & makes the JVM a child of your login shell. When the shell exits, the process gets hung up with it. nohup and screen both fix that particular symptom, and neither fixes the two problems underneath it:

  • Nothing restarts it. If the JVM dies at 3am β€” a bug, the OOM killer, a dependency that went away β€” it stays dead until somebody notices.
  • Nothing starts it after a reboot. The instance reboots for a kernel update and your API is gone.

A service manager provides three guarantees rather than one: start it now, start it again if it stops, and start it on boot. On any current Linux server that manager is systemd, and it wants one file.

The unit file, and only the lines that earn their place

[Unit]
Description=Books API
After=network.target

[Service]
User=books
Group=books
WorkingDirectory=/opt/books
ExecStart=/usr/bin/java -XX:MaxRAMPercentage=75 -jar /opt/books/app.jar
SuccessExitStatus=143
Restart=always
RestartSec=5
Environment=SPRING_PROFILES_ACTIVE=prod

[Install]
WantedBy=multi-user.target

Short enough to read in one pass, and every line is doing something:

ExecStart uses an absolute path. There is no interactive shell here and no PATH to fall back on. /usr/bin/java, not java. If you run several JDKs, this is also where you pin which one β€” the server only needs a JRE to run a jar, since the compiler is a JDK tool and nothing on the server compiles anything.

User and Group. Without them the service runs as root. A process that reads one jar and opens one port has no reason to own the machine.

Restart=always with RestartSec=5. Restart on any exit, wait five seconds first. The delay matters: without it, a service that fails at startup spins as fast as the JVM can boot and fail.

WantedBy=multi-user.target is what systemctl enable hooks into, and it is why the service comes back after a reboot.

Installing it is four commands, and the order matters β€” systemd does not notice a new or edited file on its own:

sudo useradd --system --create-home books
sudo systemctl daemon-reload      # re-read unit files from disk
sudo systemctl enable books       # start on boot, via WantedBy above
sudo systemctl start books        # start it now
sudo systemctl status books       # is it actually up?

daemon-reload is the one people forget after editing a unit. Without it systemd keeps serving the version it last read, and you spend ten minutes wondering why a change had no effect.

This is not the only supported route, either. Spring Boot documents making the jar itself installable as a service, among several deployment options. A plain unit file is the one worth learning first, because it is the same file whatever is inside the jar and the same thing you would write for any other process on the box.

The one line most unit files are missing

SuccessExitStatus=143, and it is worth understanding rather than copying.

systemctl stop sends SIGTERM. Spring Boot handles it: on 4.1.1, with nothing configured, the application drains its requests and exits:

INFO o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
INFO o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete

And then it exits 143 β€” which is 128 plus signal 15, the standard way a process reports "I was terminated by SIGTERM". Perfectly normal, and non-zero.

systemd treats a non-zero exit as a failure unless you say otherwise. So here is the same clean systemctl stop, run twice against units that differ by that one line:

Unit Result systemctl is-failed
with SuccessExitStatus=143 success not failed
without it exit-code failed

ExecMainStatus is 143 in both cases. The only difference is what systemd makes of it.

There is a subtlety here worth having, because the manual looks like it contradicts the table. systemd.service(5) says a service is already considered to have terminated successfully on exit status 0 "and, except for Type=oneshot, the signals SIGHUP, SIGINT, SIGTERM, and SIGPIPE". So why is a SIGTERM stop a failure?

Because Spring Boot does not get killed by SIGTERM β€” it catches it. The shutdown hook runs, requests drain, and the JVM then exits normally, of its own accord, with status 143. From systemd's point of view that is not "terminated by signal" at all; it is an ordinary exit with a non-zero code, and the default allowance does not apply to it. Handling the signal well is exactly what takes you outside the default.

SuccessExitStatus takes a list, so you can name several statuses if your process has more than one clean way to finish.

Common mistake

Leaving that line out and then wiring alerts to service state. Every deploy, every restart, every routine stop marks the unit failed. Within a fortnight everyone has learned to ignore the alert, which is worse than never having had it.

Check the file before you trust it

systemd-analyze verify /etc/systemd/system/books.service

It reads a unit offline and reports what is wrong without starting anything. On a machine where the binary is missing it says so and nothing else:

books.service: Command /usr/bin/java is not executable: No such file or directory

Now the reason to bother. Change Restart=always to Restart=alway β€” one missing character β€” and run it again:

Failed to parse service restart specifier, ignoring: alway

Read that last word. ignoring. The unit is not rejected, the service starts perfectly, and the restart behaviour you thought you configured does not exist. You find out the first time the process dies, which will be the worst possible time. systemd-analyze verify costs a second and catches it.

Watching it survive a crash

With the unit installed and systemctl start books, the service is active and holds a main process:

systemctl show -p MainPID --value books.service
79

Kill it as violently as possible β€” kill -9 cannot be caught or handled:

is-active   : active
MainPID     : 179
NRestarts   : 1

A new process, the counter incremented, no intervention. Measured from kill -9 to the endpoint answering again: 6 seconds β€” five of them the RestartSec delay, and about one the JVM starting.

That gap is worth knowing, because it is what a health check has to tolerate and what a load balancer will see. It is also why RestartSec is a trade rather than a number to minimise: shorter means faster recovery from a one-off, and a tighter spin when the failure is permanent.

Warning

Restart=always does not fix a crash loop, it hides one. If the application fails at startup β€” a bad config, a database that is not there β€” systemd will restart it forever and the service will report active in between. systemctl status shows the restart count; watch it after a deploy.

Logs, without configuring logging

The unit says nothing about log files, and there are none. Everything the application writes to stdout goes to the journal:

journalctl -u books.service -f
java[179]: INFO o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
systemd[1]: books.service: Deactivated successfully.
systemd[1]: books.service: Consumed 3.103s CPU time, 145.3M memory peak, 0B memory swap peak.

The application's own lines and systemd's accounting in one stream, already rotated, already timestamped, filterable by unit and by time. That last line is free per-service CPU and peak-memory accounting that you did not have to instrument.

The environment a service does not have

This one costs people an afternoon reliably. systemd does not read your shell profile. Not .bashrc, not .profile, not /etc/profile.d/ β€” those belong to login shells, and a unit is not one.

So JAVA_HOME set the usual way is invisible here, and so is every other variable your application expects. The unit is where you say it:

Environment=SPRING_PROFILES_ACTIVE=prod
EnvironmentFile=/etc/books/env

Environment= for one or two values that are fine in a world-readable file. EnvironmentFile= for a set β€” and specifically for anything secret, because the unit file is readable by everyone and a file at /etc/books/env can be chmod 600 and owned by the service user.

How big does the box actually need to be?

The received answer is that a 1 GB instance is too small for Spring Boot. For a small service that is measurably wrong, and the measurements are easy to take.

The REST API from earlier in this series, running under a 1 GiB limit:

Measurement Value
Memory in use 192.8 MiB (18.83% of the limit)
systemd's reported peak 145.3M
Starts inside a 256 MB limit yes β€” Started BooksApplication in 0.736 seconds
Maven build under 1 GB succeeds, peaking at 339 MB

Both building and running fit comfortably on a machine everybody says is too small. If you are choosing between the small instance classes, the memory column is not the thing that will stop you.

The real catch is the heap ceiling, and it is not the same question. The JVM sizes its heap from the memory it is allowed, and the default is a quarter of it:

Memory available Default max heap
512 MB 128 MB
1 GB 256 MB
2 GB 512 MB
4 GB 1 GB

On a 1 GB server your application will refuse to use more than 256 MB of heap no matter how much of the machine is idle, because MaxRAMPercentage defaults to 25. That default exists for machines running many things. A box running exactly one service is the case it is wrong for:

-XX:MaxRAMPercentage=75   β†’   max heap 768 MB

Which is the flag in the ExecStart line at the top of this article. Leave headroom β€” the JVM needs memory outside the heap for metaspace, threads and code cache, and the OOM killer does not negotiate β€” but 25% on a dedicated box is leaving most of it on the floor.

Tip

Set the percentage, not -Xmx. A fixed -Xmx512m is wrong the moment the instance is resized, and nothing reminds you. A percentage is still correct on a bigger box.

What this does not cover

Getting the jar onto the server, terminating TLS, and putting something in front of port 8080 are all separate decisions, and none of them are systemd's job β€” a reverse proxy or a load balancer handles the last two, and where the machine itself lives is a question about instance types and their trade-offs rather than about running a process.

What the unit above gives you is the part underneath all of that: a jar that starts on boot, comes back six seconds after being killed, logs somewhere you can read, and does not lie to your monitoring when you stop it on purpose.

Frequently asked questions

Why does my app stop when I close the SSH session?
Because `java -jar app.jar &` makes the JVM a child of your login shell, and the shell going away takes it with it. nohup keeps it alive for that session, but nothing restarts it if it crashes and nothing starts it after a reboot. A service manager is what provides those.
What is SuccessExitStatus=143 for?
Spring Boot exits with status 143 when it receives SIGTERM, which is what systemctl stop sends. 143 is 128 plus signal 15 and is entirely normal, but systemd treats a non-zero exit as a failure unless told otherwise. Without that line, `systemctl is-failed` reports failed after every clean stop.
How much memory does a Spring Boot app need?
Less than you have been told. The small REST API measured here uses 192.8 MiB and starts inside a 256 MB limit. What catches people is not the machine but the heap ceiling: the JVM takes 25% of available memory by default, so on a 1 GB box the maximum heap is 256MB regardless of how much is free.
Why can systemd not see my JAVA_HOME?
Because it never reads your shell profile. JAVA_HOME exported from .bashrc or /etc/profile.d exists for login shells, and a systemd unit is not one. Set what the service needs with Environment= in the unit, or point EnvironmentFile= at a file of KEY=value lines.
Do I need Restart=always if my app never crashes?
It is not only for crashes. It also covers the OOM killer, a failed dependency at startup, and anything that takes the process down while you are asleep. Measured here, the gap between kill -9 and the endpoint answering again was six seconds, most of which was the RestartSec delay.

References

  1. Spring Boot How-to: Installing as a ServiceSpring
  2. Spring Boot How-to: DeployingSpring
  3. Spring Boot Reference: Graceful ShutdownSpring
  4. systemd.service(5)Linux man-pages
  5. java command β€” JDK 21 tool specificationOracle
  6. Amazon EC2 instance typesAWS