Article
In the quest for maximum security and minimal attack surface in containerized Java applications, developers are increasingly turning to an extreme solution: scratch base images. Moving beyond even the most minimal Linux distributions, scratch provides an empty canvas—literally starting from nothing—forcing a radical rethinking of how we package and deploy Java applications.
What is a Scratch Base Image?
The scratch image is Docker's reserved, minimal base image. It contains nothing: no shell, no package manager, no libraries, not even /bin/sh. For Java applications, this means you must bundle absolutely everything your JVM needs to run into your final image, resulting in an incredibly small and secure container.
Why Consider Scratch for Java?
- Zero Attack Surface: With no OS packages, there are no CVEs to patch in the base OS. The only components are your JVM and your application.
- Minimal Image Size: Scratch-based Java images can be under 50MB, compared to 300MB+ for standard JRE images.
- No Shell, No Problem: The absence of
/bin/shmeans that even if an attacker compromises your application, they cannot execute arbitrary shell commands. - Forced Best Practices: Scratch forces you to think carefully about every file you include, eliminating "just in case" dependencies.
The Challenge: Java on Scratch
Java is notoriously heavy and expects a POSIX environment. The standard JRE assumes the presence of:
- Standard C libraries (
libc) /etc/passwdand/etc/group/tmpdirectory- Timezone data
- CA certificates
Running Java on scratch means you must provide all these essentials manually or find creative workarounds.
Building a Java Application on Scratch
1. The Native Image Approach (GraalVM)
The most practical path to scratch is using GraalVM Native Image to compile your Java application to a standalone native binary:
# Stage 1: Build the native binary FROM ghcr.io/graalvm/native-image:22-ol9 AS builder WORKDIR /app COPY . . # Install Maven (GraalVM image doesn't include it) RUN microdnf install maven -y # Build native image RUN mvn -Pnative native:compile # Stage 2: Create scratch image FROM scratch # Copy the native binary COPY --from=builder /app/target/myapp /myapp # Copy necessary root certificates (optional, if making HTTPS calls) # COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Non-root user context (Linux-only, numeric IDs) USER 1000:1000 # Run the application CMD ["/myapp"]
Result: A ~20-30MB container containing only your application binary.
2. The Custom JRE Approach (jlink)
For applications that need full JVM capabilities, create a custom JRE using jlink:
# Stage 1: Build custom JRE FROM eclipse-temurin:17-jdk AS jre-builder # Create a custom JRE with only needed modules RUN $JAVA_HOME/bin/jlink \ --add-modules java.base,java.logging,java.xml,java.sql \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress=2 \ --output /custom-jre # Stage 2: Build application FROM eclipse-temurin:17-jdk AS app-builder WORKDIR /app COPY . . RUN ./mvnw clean package -DskipTests # Stage 3: Assemble scratch image FROM scratch # Copy custom JRE COPY --from=jre-builder /custom-jre /java # Copy application JAR COPY --from=app-builder /app/target/myapp.jar /app.jar # Create necessary directory structure COPY --from=app-builder /etc/passwd /etc/passwd COPY --from=alpine:latest /etc/group /etc/group COPY --from=alpine:latest /tmp /tmp # Copy timezone data if needed COPY --from=alpine:latest /usr/share/zoneinfo /usr/share/zoneinfo # Copy CA certificates for SSL COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Set environment ENV PATH="/java/bin:$PATH" ENV JAVA_HOME="/java" # Run as non-root user (user must exist in copied /etc/passwd) USER 1000:1000 # Entrypoint ENTRYPOINT ["java", "-jar", "/app.jar"]
3. The Static Binary Hack (Using musl libc)
For Spring Boot applications that need a full JVM:
# Use Alpine to get musl libc FROM alpine:latest as libc-builder RUN apk add --no-cache openjdk17-jre # Build application FROM maven:3.8-eclipse-temurin-17 AS build WORKDIR /app COPY . . RUN mvn clean package -DskipTests # Create final scratch image FROM scratch # Copy musl libc and essential libraries COPY --from=libc-builder /lib/ld-musl-x86_64.so.1 /lib/ COPY --from=libc-builder /lib/libz.so.1 /lib/ COPY --from=libc-builder /usr/lib/libstdc++.so.6 /usr/lib/ # Copy JRE from Alpine COPY --from=libc-builder /usr/lib/jvm/java-17-openjdk/jre /java # Copy application COPY --from=build /app/target/myapp.jar /app.jar # Copy minimal /etc files COPY --from=alpine:latest /etc/passwd /etc/passwd COPY --from=alpine:latest /etc/group /etc/group # Set up timezone (optional) ENV TZ=UTC # Set Java environment ENV JAVA_HOME=/java ENV PATH="$JAVA_HOME/bin:$PATH" USER 1000:1000 CMD ["java", "-jar", "/app.jar"]
Best Practices for Scratch-Based Java Applications
1. Security Considerations
# Always run as non-root USER 65534:65534 # nobody user # Make filesystem read-only where possible RUN chmod -R 555 /java && chmod 444 /app.jar # No shell means no debugging - add health checks HEALTHCHECK --interval=30s --timeout=3s --start-period=60s \ CMD ["/java/bin/java", "-cp", "/app.jar", "HealthCheck"]
2. Essential Files to Include
Create a minimal filesystem structure:
/etc/passwd # Minimal: root:x:0:0:root:/root:/sbin/nologin\napp:x:1000:1000::/home/app:/sbin/nologin /etc/group # Minimal: root:x:0:\napp:x:1000: /tmp # Empty directory with 1777 permissions /etc/ssl/certs/ # CA certificates for HTTPS /usr/share/zoneinfo/ # Timezone database
3. Debugging Scratch Containers
Since there's no shell, debugging requires creative approaches:
// Add debugging endpoint to your Java application
@RestController
public class DebugController {
@GetMapping("/debug/env")
public Map<String, String> getEnvironment() {
return System.getenv();
}
@GetMapping("/debug/files")
public List<String> listFiles(@RequestParam String path) {
try {
return Files.walk(Paths.get(path))
.map(Path::toString)
.collect(Collectors.toList());
} catch (IOException e) {
return List.of("Error: " + e.getMessage());
}
}
}
4. Build Automation with Maven/Gradle
Create build plugins to automate scratch image creation:
<!-- Maven plugin configuration --> <plugin> <groupId>com.google.cloud.tools</groupId> <artifactId>jib-maven-plugin</artifactId> <configuration> <from> <image>scratch</image> </from> <container> <appRoot>/app</appRoot> <user>1000</user> <entrypoint> <arg>java</arg> <arg>-jar</arg> <arg>/app/myapp.jar</arg> </entrypoint> </container> </configuration> </plugin>
Production Considerations
1. Logging Without /dev/stdout
# Java applications can write to /proc/1/fd/1 (stdout) and /proc/1/fd/2 (stderr) ENV JAVA_OPTS="-Dlogging.file.name=/proc/1/fd/1"
2. Resource Limits
# Kubernetes deployment with resource limits apiVersion: apps/v1 kind: Deployment spec: template: spec: containers: - name: java-scratch image: myapp:scratch resources: requests: memory: "64Mi" cpu: "100m" limits: memory: "128Mi" cpu: "200m" securityContext: runAsNonRoot: true runAsUser: 1000 readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"]
3. Monitoring and Metrics
Add monitoring to your Java application:
// Use Micrometer for metrics
@Bean
MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config().commonTags("runtime", "scratch-java");
}
// Health indicators
@Component
public class ScratchHealthIndicator implements HealthIndicator {
@Override
public Health health() {
// Check essential directories
Path[] essentialPaths = {
Paths.get("/tmp"),
Paths.get("/etc/passwd"),
Paths.get("/etc/group")
};
for (Path path : essentialPaths) {
if (!Files.exists(path)) {
return Health.down()
.withDetail("missing", path.toString())
.build();
}
}
return Health.up().build();
}
}
When NOT to Use Scratch for Java
- Complex Dependencies: Applications requiring native libraries (JNI), extensive filesystem operations, or dynamic class loading.
- Debugging Needs: Development environments where you need shell access for troubleshooting.
- Legacy Applications: Older Java applications that rely on specific OS features.
- Third-Party Integrations: Applications that integrate with systems requiring specific OS configurations.
Conclusion
Scratch base images represent the ultimate expression of minimalist, security-focused container design for Java applications. While challenging to implement, they offer unparalleled security benefits: near-zero attack surface, minimal image size, and forced adherence to security best practices.
For Java teams willing to invest in the tooling and processes, scratch-based deployments provide a compelling security story, particularly for security-sensitive applications in regulated industries. Whether through GraalVM Native Image for truly minimal deployments or custom JRE approaches for traditional applications, scratch images offer a path to the most secure Java container deployments possible today. The journey requires rethinking assumptions and embracing constraints, but the result—a Java application that runs on nothing but its own binary—is both elegant and extraordinarily secure.
Advanced Java Container Security, Sandboxing & Trusted Runtime Environments
https://macronepal.com/blog/sandboxing-java-applications-implementing-landlock-lsm-for-enhanced-container-security/
Explains using Linux Landlock LSM to sandbox Java applications by restricting file system and resource access without root privileges, improving application-level isolation and reducing attack surface.
https://macronepal.com/blog/gvisor-sandbox-integration-in-java-complete-guide/
Explains integrating gVisor with Java to provide a user-space kernel sandbox that intercepts system calls and isolates applications from the host operating system for stronger security.
https://macronepal.com/blog/selinux-for-java-mandatory-access-control-for-jvm-applications/
Explains how SELinux enforces Mandatory Access Control (MAC) policies on Java applications, strictly limiting what files, processes, and network resources the JVM can access.
https://macronepal.com/java/a-comprehensive-guide-to-intel-sgx-sdk-integration-in-java/
Explains Intel SGX integration in Java, allowing sensitive code and data to run inside secure hardware enclaves that remain protected even if the OS is compromised.
https://macronepal.com/blog/building-a-microvm-runtime-with-aws-firecracker-in-java-a-comprehensive-guide/
Explains using AWS Firecracker microVMs with Java to run workloads in lightweight virtual machines that provide strong isolation with near-container performance efficiency.
https://macronepal.com/blog/enforcing-mandatory-access-control-implementing-apparmor-for-java-applications/
Explains AppArmor security profiles for Java applications, enforcing rules that restrict file access, execution rights, and system-level permissions.
https://macronepal.com/blog/rootless-containers-in-java-secure-container-operations-without-root/
Explains running Java applications in rootless containers using Linux user namespaces so containers operate securely without requiring root privileges.
https://macronepal.com/blog/unlocking-container-security-harnessing-user-namespaces-in-java/
Explains Linux user namespaces, which isolate user and group IDs inside containers to improve privilege separation and enhance container security for Java workloads.
https://macronepal.com/blog/secure-bootstrapping-in-java-comprehensive-trust-establishment-framework/
Explains secure bootstrapping in Java, focusing on how systems establish trust during startup using secure key management, identity verification, and trusted configuration loading.
https://macronepal.com/blog/securing-java-applications-with-chainguard-wolfi-a-comprehensive-guide-2/
Explains using Chainguard/Wolfi minimal container images to secure Java applications by reducing unnecessary packages, minimizing vulnerabilities, and providing a hardened runtime environment.