Building Java Applications with CleanStart
CleanStart provides production-hardened Java images (cleanstart/java) with OpenJDK 21, 17, 11 with security patches, Maven and Gradle pre-installed, multi-stage build support, JVM tuning for containers, and FIPS 140-3 compliance available.
The following diagram illustrates the Java application build and deployment workflow:
graph LR A["Java Source<br/>pom.xml<br/>App.java"] -->|Maven<br/>Build| B["cleanstart/java:21<br/>Builder"] B -->|Compile| C["javac<br/>Maven compile"] B -->|Package| D["Maven package<br/>Create JAR"] B -->|Test| E["Maven test<br/>Run tests"] D -->|Artifact| F["myapp.jar<br/>15 MB"] E -->|Pass| G["Build Success"] F -->|Copy| H["Production Stage<br/>cleanstart/java:21"] G -->|Continue| H H -->|Setup| I["Create App User<br/>Non-root"] H -->|Configure| J["JVM Options<br/>Container Tuning<br/>Xmx, Xms"] I -->|Security| K["Read-only FS<br/>Port 8080<br/>Healthcheck"] J -->|Entrypoint| L["java -jar<br/>myapp.jar"] K -->|Final| M["Container Image<br/>cleanstart/java<br/>Secure Runtime"] L -->|Deploy| N["Kubernetes Pod<br/>or Docker"] N -->|Expose| O["Port 8080<br/>REST API"] O -->|Request| P["curl localhost:8080"] P -->|Response| Q["JSON Output<br/>Hello from<br/>CleanStart"] style A fill:#ccffcc style B fill:#99ccff style D fill:#ffff99 style F fill:#ffff99 style H fill:#99ff99 style M fill:#99ff99 style Q fill:#99ff99Quick Start: A Spring Boot Application
Step 1: Initialize Your Project
Using Maven:
mvn archetype:generate \ -DgroupId=com.example \ -DartifactId=my-java-app \ -DarchetypeArtifactId=maven-archetype-quickstart cd my-java-appOr use Spring Boot starter:
curl https://start.spring.io/starter.zip \ -d dependencies=web,actuator \ -d packageName=com.example \ -d name=MyApp -o myapp.zipunzip myapp.zipcd my-appStep 2: Create Your Application
Create src/main/java/com/example/App.java:
package com.example; import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;import org.slf4j.Logger;import org.slf4j.LoggerFactory; @SpringBootApplication@RestControllerpublic class App { private static final Logger logger = LoggerFactory.getLogger(App.class); @GetMapping("/") public Map<String, String> hello() { logger.info("Hello endpoint called"); return Map.of( "message", "Hello from CleanStart", "version", "1.0" ); } public static void main(String[] args) { SpringApplication.run(App.class, args); }}Update pom.xml:
<project> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>my-java-app</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.0</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project>Step 3: Create Production Dockerfile
# Build stageFROM cleanstart/java:21 AS builder WORKDIR /appCOPY pom.xml .COPY src ./src # Build the applicationRUN mvn clean package -DskipTests -q # Production stageFROM cleanstart/java:21 WORKDIR /app # Create non-root userRUN useradd -m -u 1000 appuser # Copy JAR from builderCOPY --from=builder /app/target/*.jar app.jar # Extract layers for optimized startupRUN java -Djarmode=layertools -jar app.jar extract USER appuserEXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75.0"CMD ["java", "-jar", "app.jar"]Step 4: Build and Run
# Build the Docker imagedocker build -t my-java-app:latest . # Run locallydocker run --rm -p 8080:8080 my-java-app:latest # Test the applicationcurl http://localhost:8080/curl http://localhost:8080/actuator/healthMulti-Stage with Layer Extraction
Spring Boot 2.3+ supports layer extraction for optimized Docker caching:
# Build stageFROM cleanstart/java:21 AS builder WORKDIR /appCOPY . .RUN mvn clean package -DskipTests -q # Extract stageFROM cleanstart/java:21 AS extractor WORKDIR /appCOPY --from=builder /app/target/*.jar app.jarRUN java -Djarmode=layertools -jar app.jar extract # Production stageFROM cleanstart/java:21 WORKDIR /appRUN useradd -m -u 1000 appuser # Copy layers in order of frequency changeCOPY --from=extractor /app/dependencies ./COPY --from=extractor /app/spring-boot-loader ./COPY --from=extractor /app/snapshot-dependencies ./COPY --from=extractor /app/application ./ USER appuserEXPOSE 8080 ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75.0 -XX:+ParallelRefProcEnabled" HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 CMD ["java", "-cp", ".", "org.springframework.boot.loader.JarLauncher"]Gradle Support
If using Gradle instead of Maven:
FROM cleanstart/java:21 AS builder WORKDIR /appCOPY . . RUN ./gradlew clean bootJar -x test --no-daemon FROM cleanstart/java:21 WORKDIR /appRUN useradd -m -u 1000 appuser COPY --from=builder /app/build/libs/*.jar app.jar USER appuserEXPOSE 8080 ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75.0" CMD ["java", "-jar", "app.jar"]Testing
Maven Tests in Docker
FROM cleanstart/java:21 AS test WORKDIR /appCOPY . . RUN mvn clean testRUN mvn verifyRun tests:
docker build --target test -t my-java-app:test .Production Configuration
Externalized Configuration
Use environment variables with Spring properties:
# application.propertiesserver.port=${PORT:8080}logging.level.root=${LOG_LEVEL:INFO}spring.datasource.url=${DATABASE_URL:jdbc:h2:mem:testdb}server.shutdown=gracefulspring.lifecycle.timeout-per-shutdown-phase=30sRun with secrets:
docker run \ -e PORT=8080 \ -e LOG_LEVEL=INFO \ -e DATABASE_URL='jdbc:postgresql://db:5432/myapp' \ my-java-app:latestLogging to Stdout
Configure Logback for container logging:
<!-- logback-spring.xml --><configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern> %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n </pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="STDOUT" /> </root></configuration>Health Checks
Spring Boot Actuator provides health endpoints:
curl http://localhost:8080/actuator/healthFor custom health checks:
@Componentpublic class DatabaseHealthCheck implements HealthIndicator { @Override public Health health() { try { // Check database connectivity return Health.up().build(); } catch (Exception e) { return Health.down().withDetail("error", e.getMessage()).build(); } }}JVM Tuning for Containers
Memory Configuration
ENV JAVA_OPTS="-XX:+UseG1GC \ -XX:MaxRAMPercentage=75.0 \ -XX:+ParallelRefProcEnabled \ -XX:+UnlockDiagnosticVMOptions \ -XX:G1SummarizeRSetStatsPeriod=1"CPU Configuration
docker run \ --cpus="2" \ --memory="2g" \ my-java-app:latestThe JVM automatically detects container limits.
Security Best Practices
Non-Root User
RUN useradd -m -u 1000 appuserUSER appuserMinimal Base Image
CleanStart Java images are minimal OpenJDK without unnecessary packages.
Dependency Scanning
# Check for CVEs in dependenciesmvn dependency-check:check # Update dependenciesmvn versions:display-dependency-updatesJVM Security Options
ENV JAVA_OPTS="-Xmx512m \ -Djava.security.manager=allow \ -Dcom.sun.net.httpserver.HttpServer.maxConnections=1000"Deploying to Cloud Run
# Build and push to Google Container Registrygcloud builds submit --tag gcr.io/my-project/my-java-app # Deploy to Cloud Rungcloud run deploy my-java-app \ --image gcr.io/my-project/my-java-app:latest \ --platform managed \ --region us-central1 \ --allow-unauthenticated \ --memory 1Gi \ --cpu 1 \ --timeout 300 \ --set-env-vars PORT=8080,LOG_LEVEL=INFOImage Options
Image | Use Case |
|---|---|
| Latest LTS, new features |
| Stable production (recommended) |
| Legacy applications |
| FIPS 140-3 compliance |
Performance Tips
Use G1GC as the default for most workloads. Layer extraction in Spring Boot 2.3+ improves startup time. GraalVM offers native-image compilation for sub-second startup. Reduce dependencies since each one increases startup time. Monitor with the Actuator endpoint at /metrics.
Troubleshooting
Build Issues
Problem: Maven or Gradle build fails with "connection refused" or timeout Cause: Repository mirrors not configured; network isolation in Docker; dependency not in default repos; firewall blocking Fix: In CleanStart container, specify Maven mirror in pom.xml or settings.xml. For Gradle, add to build.gradle: repositories { mavenCentral() }. Use BuildKit with inline caching: docker buildx build --cache-from=type=inline. Or pre-download dependencies in separate stage and cache the layer.
Problem: JAR build succeeds but file not found in production stage Cause: Wrong output path in Dockerfile; Gradle build generates shadow JAR with different name; Maven output in target/ not copied correctly Fix: Verify build command output: run locally first (mvn clean package, ./gradlew bootJar). Check exact filename: ls -la target/ or ls -la build/libs/. In Dockerfile, use wildcard carefully: COPY --from=builder /app/target/*.jar app.jar (ensure only one .jar exists). For Gradle with shadow JAR: COPY --from=builder /app/build/libs/*-all.jar app.jar.
Problem: Gradle wrapper downloads during build; very slow or fails Cause: Gradle wrapper not committed; network issues; mirror not reachable Fix: Commit gradlew and gradle/ directory to git. Or download in Docker: RUN curl -L https://services.gradle.org/distributions/gradle-8.0-bin.zip -o /tmp/gradle.zip && unzip /tmp/gradle.zip. Use gradle cache layer: docker buildx build --cache-from=type=registry or mount cache: RUN --mount=type=cache,target=/root/.gradle ./gradlew clean build.
Problem: Dependency-check or security scanning adds 10+ minutes to build Cause: Dependency-check plugin downloads NVD database on every build; network latency; large dependency tree Fix: Run security scans in CI separately, not in Docker build. Remove check-maven-plugin or dependency-check plugin from build stage. Run locally or in separate pipeline step. Or cache the CVE database: docker run --mount=type=cache,target=/root/.m2 maven mvn clean package to persist Maven cache across builds.
Problem: Memory or disk space exhausted during Maven/Gradle build Cause: Docker memory/disk limits too low; Maven/Gradle heap not sized for container; dependency cache not cleaned Fix: Increase Docker resources: docker build --memory=4g or docker buildx build --memory=4g. Set Maven heap: ENV MAVEN_OPTS="-Xmx2g". Gradle: org.gradle.jvmargs=-Xmx2g. After build, clean: RUN mvn clean or RUN rm -rf /root/.m2/repository. Use multi-stage to discard build artifacts: final image should only contain JAR and runtime.
Problem: Layer extraction (java -Djarmode=layertools) fails or produces empty layers Cause: Spring Boot version doesn't support layertools; JAR not fat JAR; wrong JAR mode argument; JAR structure changed Fix: Verify Spring Boot 2.3+: mvn dependency:tree | grep spring-boot-maven-plugin. Ensure using spring-boot-maven-plugin (not maven-shade-plugin). For non-Spring FAT JARs, skip layer extraction and use regular copy. Test extraction locally: java -Djarmode=layertools -jar app.jar extract creates dependencies/, spring-boot-loader/, etc.
Runtime Issues
Problem: OutOfMemoryError: Java heap space or OutOfMemoryError: GC overhead limit exceeded Cause: -Xmx set too low; -XX:MaxRAMPercentage incorrect; too many large objects; memory leak; GC thrashing Fix: Check memory assigned: docker stats while running, watch MemLimit and MemUsage. Increase container memory: docker run -m 2g or --memory 2Gi in Kubernetes. Adjust JVM heap: ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0" (use 75-80% of container RAM). For heap dump on OOM: add -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp. If memory still insufficient, profile with jmap or enable JFR (Java Flight Recorder).
Problem: OutOfMemoryError: Java heap space with -XX:MaxRAMPercentage=75.0 set Cause: Percentage applied to wrong memory target; Java process not seeing container memory limits; pre-JDK 10 doesn't respect cgroups Fix: Verify Java version respects cgroups: Java 9 with update or Java 10+. Run docker run my-app java -XX:+PrintFlagsFinal -version | grep MaxHeapSize. Set explicit -Xmx if percentage doesn't work: ENV JAVA_OPTS="-Xmx512m". In Kubernetes, set requests.memory below limits.memory to leave headroom for off-heap allocations (direct buffers, metaspace).
Problem: Spring Boot application takes 30+ seconds to start Cause: Large dependency graph; component scanning slow; database connection attempts on startup; classpath scanning Fix: Profile startup: enable debug=true in application.properties and examine startup logs. Defer non-essential beans: @Lazy on heavy components. Disable unused autoconfiguration: spring.autoconfigure.exclude=.... For production, consider GraalVM native-image compilation (sub-second startup). Preload Spring Application Cache: Spring 5.3+ has improvements, ensure using latest stable version.
Problem: JVM container awareness - Java reporting wrong CPU/memory values Cause: Pre-Java 10; -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap needed; Java not reading /sys/fs/cgroup correctly Fix: Use Java 11+ (automatically respects container limits). For Java 8-10, enable cgroup support: ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap". Verify: docker run my-app java -XshowSettings:vm -version. Should show Heap values matching container memory, not host memory.
Problem: Port binding fails - "Address already in use" or "Permission denied on port < 1024" Cause: Previous container still holding port; binding to wrong interface; application tries to use privileged port (< 1024) as non-root Fix: Application must bind to 0.0.0.0 not localhost: server.address=0.0.0.0 in application.properties. Use PORT > 1024 when running non-root. Verify: docker run -p 8080:8080 my-app should map correctly. Check for orphaned containers: docker ps -a and docker rm.
Problem: Graceful shutdown not working; connections dropped during SIGTERM Cause: No shutdown hook registered; application ignores SIGTERM; container kill timeout too short Fix: Spring Boot handles SIGTERM by default (9+ seconds graceful period). Verify: server.shutdown=graceful in application.properties. Test: docker kill -s TERM container-id should show "Undertow started" then shutdown logs. Increase Docker timeout: docker stop --time=30 container-id. Explicitly register shutdown hook if needed (rare with Spring).
Problem: HEALTHCHECK fails; endpoints return 500 or timeout Cause: Application not ready; Actuator disabled; endpoint path wrong; port binding issue; curl/wget not available in shell-less containers Fix: Verify endpoint exists: curl -v http://localhost:8080/actuator/health (locally, outside container). Enable Actuator in pom.xml (included in spring-boot-starter-web). Health endpoint path configurable: management.endpoints.web.base-path=/actuator. Increase health check timeout: HEALTHCHECK --interval=30s --timeout=5s --start-period=30s. For shell-less containers, use exec form with JVM: HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 CMD ["java", "-cp", "/app/app.jar", "com.example.HealthCheck"] (requires a health check class). Or test health from outside the container: docker run -d -p 8080:8080 my-app && curl -f http://localhost:8080/actuator/health.
Problem: Application can't write to logs or temporary files; permission denied Cause: /tmp or /var/log mounted as read-only; application running as wrong UID; log directory not writable Fix: Mount writable emptyDir in Kubernetes for /tmp. In Dockerfile, create log directory: RUN mkdir -p /app/logs && chown -R appuser:appuser /app/logs. For read-only root filesystem, mount /tmp and /var/tmp as writable emptyDir volumes. Ensure application user can write: RUN useradd -m -u 1000 appuser && chown appuser:appuser /app.
Problem: Metaspace memory grows unbounded; "Out of Memory: Metaspace" after hours Cause: Class unloading not occurring (ParallelGC, not G1GC); dynamic class generation (bytecode generation); memory leak in classloaders Fix: Use G1GC: ENV JAVA_OPTS="-XX:+UseG1GC" (default for containers, but verify). Enable class unloading: -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassUnloading. Set Metaspace limit: -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m. For frameworks doing bytecode generation (CGLIB, Hibernate), consider limiting or monitoring class generation. Profile with JFR.
Problem: JVM crashes with signal 11 (SIGSEGV) or "Internal Error (os_linux.cpp)" Cause: Native library crash; Java version incompatibility with container; JVM bug; hardware issue (rare) Fix: Check Java error log: usually in stdout/stderr. Update Java: FROM cleanstart/java:21 or use latest LTS. For native crashes in specific libraries, check library version compatibility. Enable JVM crash dump: -XX:+CreateCoredumpOnCrash -XX:+ErrorFileToStderr. Report with full error output if persists.
Performance & Tuning Issues
Problem: GC pauses causing application to freeze or drop connections Cause: Full GC overhead; heap size too small; wrong GC algorithm for workload; GC not tuned for low-latency Fix: Profile with JFR: enable in application.properties, export and analyze. Use G1GC tuning: -XX:+UseG1GC -XX:MaxGCPauseMillis=200 (target 200ms pause). For ultra-low-latency, consider ZGC (Java 15+): -XX:+UseZGC. Increase heap if GC frequency too high. Monitor with Micrometer actuator: curl http://localhost:8080/actuator/metrics/jvm.gc.pause.
Problem: High CPU usage even with low request volume Cause: Busy-wait loops; excessive logging; inefficient queries; spinning threads; GC overhead Fix: Profile CPU: enable JFR in application.properties and check if CPU time is in GC, application code, or I/O. Check logs: excessive debug logging impacts performance. Verify database queries with slow-query log. Use async servlets for I/O-heavy operations. Monitor heap: if GC constantly running, increase heap.
Problem: Heap dumps are massive (> 1GB) making analysis difficult Cause: Large legitimate data structures; memory leak accumulating; heap not sized correctly Fix: Capture heap dump at specific time: docker exec container jmap -dump:format=b,file=/tmp/heap.dump <pid>. Use jhsdb jhat /tmp/heap.dump locally for analysis. Or use Eclipse MAT or YourKit for sophisticated analysis. Identify object count by type. If legitimate data, increase heap. If leak suspected, narrow down object creation using allocation profiling (JFR).
Problem: Application threads exhausted; "Unable to create new native thread" Cause: Thread pool not sized correctly; too many connections; thread leak Fix: Check default thread pools: Tomcat has threadPoolSize, HikariCP has pool size. Set explicitly: server.tomcat.threads.max=50 for small containers. Pool size should match CPU: 50-100 threads per CPU core depends on workload. Monitor active threads: curl http://localhost:8080/actuator/metrics/tomcat.threads.current. Ensure threads are being pooled and reused, not created per request.
CleanStart Production Hardening
Read-Only Root Filesystem
Enforce immutable infrastructure with read-only root and controlled writable paths for Java:
apiVersion: v1kind: Podmetadata: name: java-appspec: containers: - name: app image: cleanstart/java:17 securityContext: readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp - name: java-tmp mountPath: /var/tmp - name: app-logs mountPath: /app/logs volumes: - name: tmp emptyDir: {} - name: java-tmp emptyDir: {} - name: app-logs emptyDir: {}Shell-Less ENTRYPOINT
Remove shell for attack surface reduction. Update your Dockerfile:
FROM cleanstart/java:17 WORKDIR /appCOPY app.jar . # Declarative Image Builder: # Use cleanimg-init as PID 1ENTRYPOINT ["/cleanimg-init", "--"]CMD ["java", "-jar", "app.jar"]cleanimg-customize: JVM Hardening
Inject JVM security flags and memory configuration:
FROM cleanstart/java:17 WORKDIR /appCOPY app.jar . # Configure JVM for hardening and performanceENV JAVA_OPTS="-XX:+UseG1GC \ -XX:MaxRAMPercentage=75.0 \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/tmp/heap.dump" ENTRYPOINT ["/cleanimg-init", "--"]CMD ["java", "-jar", "app.jar"]Security Context
Complete Kubernetes securityContext for hardened Java containers:
apiVersion: apps/v1kind: Deploymentmetadata: name: java-appspec: template: spec: securityContext: fsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - name: app image: cleanstart/java:17 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 capabilities: drop: - ALL resources: requests: cpu: 250m memory: 512Mi limits: cpu: 1000m memory: 1GiNext Steps
Advanced Java Deployment, Kubernetes Deployment, and GraalVM Native Images.
