The Minimalist’s Approach: Building Ultra-Secure Java Applications with Scratch Base Images

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?

  1. 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.
  2. Minimal Image Size: Scratch-based Java images can be under 50MB, compared to 300MB+ for standard JRE images.
  3. No Shell, No Problem: The absence of /bin/sh means that even if an attacker compromises your application, they cannot execute arbitrary shell commands.
  4. 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/passwd and /etc/group
  • /tmp directory
  • 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

  1. Complex Dependencies: Applications requiring native libraries (JNI), extensive filesystem operations, or dynamic class loading.
  2. Debugging Needs: Development environments where you need shell access for troubleshooting.
  3. Legacy Applications: Older Java applications that rely on specific OS features.
  4. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper