Spring Boot on Light Cloud: JAR, Port and Memory Settings

A Spring Boot 4 API from start.spring.io, built into a JAR by Light Cloud. Plus the JVM setting that tripled my usable memory without changing the plan.

Spring Boot on Light Cloud: JAR, Port and Memory Settings
On this pageShow
  1. What you will build
  2. Before you start
  3. Step 1: Generate the project
  4. Step 2: Write the API
  5. Step 3: Build and run it
  6. Step 4: Push it to GitHub
  7. Step 5: Deploy it
  8. Step 6: Give the JVM its memory
  9. How long does it take to start?
  10. Troubleshooting

To deploy a Spring Boot app on Light Cloud, generate it with Java 17, push it to GitHub and click Deploy. Light Cloud detects Spring Boot, builds the runnable JAR with your Gradle wrapper (or Maven) and runs java -jar with SERVER_PORT already set, so there is no Dockerfile and no port to configure. Then add one environment variable, JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75, to let the JVM use most of the instance's memory.

In my run that variable raised the maximum heap from 123 MB to 371 MB on a 512 MB instance.

What you will build

A small tasks API with Spring Boot 4: POST /tasks with validation, GET /tasks, the Actuator health check at /actuator/health, and a /runtime endpoint that shows what the JVM got from the container.

Source code: github.com/light-cloud-com/tutorial-spring-boot-api.

Before you start

Step 1: Generate the project

start.spring.io makes the project. This command asks it for a Gradle project with Spring Boot 4.1.1, Java 17, and the Web, Actuator and Validation starters:

terminal
$ curl -s "https://start.spring.io/starter.zip?type=gradle-project&bootVersion=4.1.1&javaVersion=17&baseDir=tutorial-spring-boot-api&artifactId=tutorial-spring-boot-api&name=tutorial-spring-boot-api&packageName=com.example.api&dependencies=web,actuator,validation" -o starter.zip
$ unzip -q starter.zip
$ cd tutorial-spring-boot-api

You can pick the same options on the website instead. Keep Java at 17: that is the version Light Cloud builds and runs with.

Step 2: Write the API

Two small records and a controller in src/main/java/com/example/api:

src/main/java/com/example/api/Task.java
java
package com.example.api;

public record Task(long id, String title, boolean done) {
}
src/main/java/com/example/api/NewTask.java
java
package com.example.api;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record NewTask(@NotBlank @Size(max = 200) String title) {
}
src/main/java/com/example/api/TaskController.java
java
package com.example.api;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

import jakarta.validation.Valid;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class TaskController {

	// In memory, so the list starts empty after every restart.
	private final Map<Long, Task> tasks = new ConcurrentHashMap<>();
	private final AtomicLong nextId = new AtomicLong(1);

	@GetMapping("/tasks")
	public List<Task> list() {
		return tasks.values().stream().sorted((a, b) -> Long.compare(a.id(), b.id())).toList();
	}

	@GetMapping("/tasks/{id}")
	public Task get(@PathVariable long id) {
		Task task = tasks.get(id);
		if (task == null) {
			throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Task not found");
		}
		return task;
	}

	@PostMapping("/tasks")
	@ResponseStatus(HttpStatus.CREATED)
	public Task create(@Valid @RequestBody NewTask body) {
		Task task = new Task(nextId.getAndIncrement(), body.title(), false);
		tasks.put(task.id(), task);
		return task;
	}

	// Shows what the JVM got from the container: useful when tuning memory.
	@GetMapping("/runtime")
	public Map<String, Object> runtime() {
		Runtime rt = Runtime.getRuntime();
		return Map.of(
				"java", Runtime.version().toString(),
				"processors", rt.availableProcessors(),
				"maxHeapMb", rt.maxMemory() / (1024 * 1024));
	}
}

And three settings:

src/main/resources/application.properties
properties
spring.application.name=tutorial-spring-boot-api

# Light Cloud sets SERVER_PORT for you. Do not hard-code server.port here.

# Only the health endpoint is public; /actuator/health answers {"status":"UP"}.
management.endpoints.web.exposure.include=health

# Stop taking new requests and finish the running ones on shutdown.
server.shutdown=graceful

The port line is a comment on purpose. Spring Boot reads the SERVER_PORT environment variable, and Light Cloud sets it to the app's port. A server.port in this file would win over it and the deploy would never become healthy.

Step 3: Build and run it

terminal
$ ./gradlew build -x test
BUILD SUCCESSFUL in 3s
$ java -jar build/libs/tutorial-spring-boot-api-0.0.1-SNAPSHOT.jar
Tomcat started on port 8080 (http) with context path '/'
Started TutorialSpringBootApiApplication in 1.441 seconds (process running for 1.78)

Output is trimmed; the first build also downloads Gradle and the dependencies. The build makes two JARs; the one without -plain is the runnable one, and it is also the one Light Cloud picks. From a second terminal:

terminal
$ curl http://localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}
$ curl -X POST http://localhost:8080/tasks -H "content-type: application/json" -d '{"title":"Deploy the Spring Boot API"}'
{"id":1,"title":"Deploy the Spring Boot API","done":false}
$ curl -X POST http://localhost:8080/tasks -H "content-type: application/json" -d '{"title":""}'
{"timestamp":"2026-09-25T12:10:47.165Z","status":400,"error":"Bad Request","path":"/tasks"}

Stop the app with Ctrl+C.

Step 4: Push it to GitHub

The generated .gitignore already keeps build and .gradle out:

terminal
$ git init -b main
$ git add -A
$ git commit -m "Spring Boot tasks API"
# Create an empty repository named tutorial-spring-boot-api on github.com/new first.
$ git remote add origin https://github.com/YOUR-USERNAME/tutorial-spring-boot-api.git
$ git push -u origin main
To https://github.com/YOUR-USERNAME/tutorial-spring-boot-api.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

Commit the gradlew script and the gradle/wrapper folder: the build uses them to fetch the exact Gradle version the project was made with.

Step 5: Deploy it

  1. In the Light Cloud console, click New..., then Deploy from GitHub, and pick tutorial-spring-boot-api. Light Cloud shows Spring Boot / Backend, based on build.gradle, and Runs on a server:

The detection banner Spring Boot Backend based on build.gradle, with Runs on a server highlighted

  1. Leave Port at 8080 and click Deploy. The first deploy took about 3 minutes in my run: Gradle downloads itself and every dependency before it compiles.

The Production overview of tutorial-spring-boot-api with the Deployed badge and the URL card highlighted

  1. Check it:
terminal
$ curl https://main-tutorial-spring-boot-api-yourworkspace.light-cloud.io/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}
$ curl https://main-tutorial-spring-boot-api-yourworkspace.light-cloud.io/runtime
{"maxHeapMb":123,"java":"17.0.20+8","processors":2}

Step 6: Give the JVM its memory

maxHeapMb is 123 on an instance with 512 MB. Inside a container the JVM reserves only a quarter of the memory for the heap by default. The rest sits unused unless you raise the share.

  1. Open Production, Settings, and click Edit next to Environment Variables.
  2. Add JAVA_TOOL_OPTIONS with the value -XX:MaxRAMPercentage=75, and click Save.

The Environment Variables editor with JAVA_TOOL_OPTIONS set to -XX:MaxRAMPercentage=75 and the Save button highlighted

The JVM reads JAVA_TOOL_OPTIONS by itself; nothing in the project changes. After the redeploy:

terminal
$ curl https://main-tutorial-spring-boot-api-yourworkspace.light-cloud.io/runtime
{"java":"17.0.20+8","maxHeapMb":371,"processors":2}

371 MB instead of 123 MB, on the same plan. The Logs tab confirms it at start-up with the line Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75. Leave the remaining quarter for the JVM's own needs: threads, loaded classes and the JIT compiler also use memory, outside the heap.

How long does it take to start?

From the logs, a new instance reached Spring's Started line about 7 seconds after it started. Spring itself reported 5.1 to 5.6 seconds; the rest is the JVM booting. For comparison, a Go API was ready in 0.27 seconds on the same platform.

With Min instances at 0 the first request after a quiet period waits for that. If that matters, set Min instances to 1 in the environment's Settings, at the cost of an instance that always runs.

Troubleshooting

The deploy never becomes healthy

Check application.properties for server.port. A fixed port wins over SERVER_PORT, so the app listens somewhere Light Cloud does not look.

The build fails when the project asks for Java 21 or newer

The build has JDK 17. If the project asks for a newer Java (JavaLanguageVersion.of(21) in build.gradle, or java.version in pom.xml), the build cannot compile it. Set it to 17 and push again.

More tutorials