A Spring Boot application packages its code, dependencies and an embedded Tomcat server into a single executable JAR, so deploying it on Linux comes down to running java -jar reliably. In this tutorial you will install Java 21 on Ubuntu 24.04, build a Spring Boot application, run it as a systemd service under a dedicated user with its configuration kept outside the JAR, and publish it through Nginx with a Let's Encrypt certificate.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM.
- A non-root user with
sudoprivileges. - A domain name (
your_domain) with an A record pointing to the server's public IP. - UFW enabled with SSH allowed (
sudo ufw allow OpenSSH && sudo ufw enable).
The guide uses a small demo application called demo. If you already have an application, build it the same way and use its JAR file name instead.
Step 1 - Installing Java 21
Spring Boot 3 and later require Java 17 or newer. Ubuntu 24.04 packages OpenJDK 21, a long-term support release. The JDK is needed to build on the server; if you build elsewhere, openjdk-21-jre-headless is enough:
sudo apt update
sudo apt install openjdk-21-jdk-headless unzip
Verify the installation:
java -version
openjdk version "21.0.8" 2025-07-15
OpenJDK Runtime Environment (build 21.0.8+9-Ubuntu-0ubuntu124.04.1)
OpenJDK 64-Bit Server VM (build 21.0.8+9-Ubuntu-0ubuntu124.04.1, mixed mode, sharing)
Step 2 - Building the application
If you do not have an application yet, generate one from Spring Initializr with the Web and Actuator starters. Actuator provides the /actuator/health endpoint you will use to check the deployment:
cd ~
curl -fsSL https://start.spring.io/starter.zip -d type=maven-project -d javaVersion=21 -d dependencies=web,actuator -d artifactId=demo -d name=demo -o demo.zip
unzip demo.zip -d demo
cd demo
If you have your own project, clone it instead and change into its directory.
Build the executable JAR with the Maven wrapper included in the project. The first build downloads Maven and all dependencies, so it takes a few minutes:
./mvnw -DskipTests package
For Gradle projects, the equivalent is ./gradlew bootJar, which writes the JAR to build/libs/. Check the result:
ls -lh target/*.jar
-rw-rw-r-- 1 your_user your_user 21M Sep 24 10:05 target/demo-0.0.1-SNAPSHOT.jar
Ignore any file ending in .jar.original; that is the thin JAR before Spring Boot repackages it.
Step 3 - Creating a service user and installing the JAR
Run the application under its own system user with no login shell, so a compromise of the application does not give access to your account:
sudo useradd --system --home-dir /opt/demo --shell /usr/sbin/nologin demo
Create the application directory, copy the JAR with a stable name and set ownership:
sudo mkdir -p /opt/demo/config
sudo cp target/demo-0.0.1-SNAPSHOT.jar /opt/demo/app.jar
sudo chown -R root:demo /opt/demo
sudo chmod 750 /opt/demo /opt/demo/config
sudo chmod 640 /opt/demo/app.jar
The files belong to root and the demo group can only read them, so the running application cannot modify its own code.
Step 4 - Externalizing the configuration
Spring Boot automatically loads config/application.properties from the directory it is started in, and those values override the ones packaged in the JAR. This lets you keep production settings on the server:
sudo nano /opt/demo/config/application.properties
server.address=127.0.0.1
server.port=8080
server.forward-headers-strategy=native
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true
logging.level.root=INFO
What these settings do:
server.address=127.0.0.1makes Tomcat listen only on localhost, so the application is reachable exclusively through Nginx.server.forward-headers-strategy=nativemakes Tomcat trust theX-Forwarded-*headers from Nginx, so redirects and generated links usehttps://and the real client IP.- The
management.*lines expose only the health and info endpoints over HTTP.
Secrets such as database passwords should not live in this file. Put them in an environment file that only root can read; systemd passes the values to the application and Spring resolves ${...} placeholders from environment variables:
sudo nano /opt/demo/demo.env
DB_PASSWORD=your_strong_password
sudo chmod 600 /opt/demo/demo.env
If your application uses Spring Data JPA with PostgreSQL, you would then add the connection settings to application.properties, reading the password from the environment:
spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=demo
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
Use validate (or a migration tool such as Flyway) in production. update lets Hibernate alter tables on startup, which is risky with real data. The demo application has no database dependency, so it ignores these lines.
Step 5 - Running the application with systemd
Create a systemd unit so the application starts at boot and restarts after a crash:
sudo nano /etc/systemd/system/demo.service
[Unit]
Description=Demo Spring Boot application
After=network.target
[Service]
User=demo
Group=demo
WorkingDirectory=/opt/demo
EnvironmentFile=/opt/demo/demo.env
ExecStart=/usr/bin/java -XX:MaxRAMPercentage=75 -jar /opt/demo/app.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target
Some details worth knowing:
SuccessExitStatus=143tells systemd that a JVM stopped bySIGTERM(exit code 128 + 15) shut down cleanly.-XX:MaxRAMPercentage=75sizes the heap relative to the server's memory instead of the JVM default of 25%. Lower it if other services share the server. You can also set a fixed heap with-Xmx.ProtectSystem,ProtectHomeandNoNewPrivilegesare cheap hardening options that restrict what the process can touch.
Load the unit, start the service and enable it at boot:
sudo systemctl daemon-reload
sudo systemctl enable --now demo
Watch the logs until the application reports that it has started:
sudo journalctl -u demo -f
... o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
... com.example.demo.DemoApplication : Started DemoApplication in 2.91 seconds (process running for 3.4)
Press CTRL+C to stop following the log, then query the health endpoint:
curl http://127.0.0.1:8080/actuator/health
{"status":"UP","groups":["liveness","readiness"]}
Confirm that the port is bound to localhost only:
sudo ss -ltnp | grep 8080
LISTEN 0 100 [::ffff:127.0.0.1]:8080 *:* users:(("java",pid=5123,fd=18))
Step 6 - Configuring Nginx as a reverse proxy
Install Nginx:
sudo apt install nginx
Create a server block for the application:
sudo nano /etc/nginx/sites-available/demo
server {
listen 80;
listen [::]:80;
server_name your_domain;
client_max_body_size 10M;
location /actuator/ {
allow 127.0.0.1;
deny all;
proxy_pass http://127.0.0.1:8080;
}
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8080;
}
}
The /actuator/ block keeps monitoring endpoints off the public Internet; you can still query them from the server itself on port 8080. Enable the site and reload Nginx:
sudo ln -s /etc/nginx/sites-available/demo /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
Request the site through Nginx. The demo app has no controllers yet, so Spring's default error response (404) confirms that the request reached the application:
curl -I http://your_domain/
HTTP/1.1 404 Not Found
Server: nginx/1.24.0 (Ubuntu)
Content-Type: application/json
Your own application will return its home page instead.
Step 7 - Enabling HTTPS
Install Certbot with the Nginx plugin and request a certificate. Certbot updates the server block and adds an HTTP to HTTPS redirect:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain
Test automatic renewal:
sudo certbot renew --dry-run
Because Tomcat trusts the forwarded headers, the application now sees requests as HTTPS and builds redirects with the https:// scheme.
Step 8 - Deploying new versions
To release a new version, build the JAR, replace the file and restart the service:
cd ~/demo
git pull
./mvnw -DskipTests package
sudo install -o root -g demo -m 640 target/demo-0.0.1-SNAPSHOT.jar /opt/demo/app.jar
sudo systemctl restart demo
Spring Boot shuts down gracefully by default since version 3.4: it stops accepting new requests and lets active ones finish before the JVM exits. Check the health endpoint again after each restart:
curl http://127.0.0.1:8080/actuator/health
Troubleshooting
The service keeps restarting: read the full error with sudo journalctl -u demo -n 100 --no-pager. UnsupportedClassVersionError means the JAR was built for a newer Java than the one installed; install the matching JDK or build with javaVersion set to 21.
Port 8080 was already in use: another process holds the port. Find it with sudo ss -ltnp | grep 8080, stop it, or change server.port together with the proxy_pass lines in Nginx.
502 Bad Gateway: the application is still starting or has crashed. Spring Boot can take several seconds to start; check systemctl status demo and the journal.
The process is killed with OutOfMemoryError or by the kernel OOM killer: the heap plus the JVM's non-heap memory exceeds available RAM. Lower MaxRAMPercentage, set an explicit -Xmx, or move to a server with more memory.
Conclusion
Your Spring Boot application now runs as a hardened systemd service under its own user, keeps its production configuration and secrets outside the JAR, and is published through Nginx over HTTPS while the Actuator endpoints stay private.
As next steps you can:
- Add a PostgreSQL database and manage schema changes with Flyway.
- Expose the
prometheusActuator endpoint (with the Micrometer Prometheus registry) and scrape it from your monitoring system. - Build the JAR in a CI pipeline and copy only the artifact to the server, so the JDK and source code do not need to live in production.
