The Problem
If you run database migrations with Flyway inside Docker containers, you’ve probably noticed the steady stream of CVE findings from your vulnerability scanner. AWS ECR image scanning, Trivy, Grype — they all flag the same thing: dozens of OS-level vulnerabilities in the base image that have nothing to do with your migrations.
The official Flyway Docker image is built on Debian with a full JDK installation. That means you’re shipping apt, bash, curl, openssl, hundreds of shared libraries, and an entire Linux userspace — all so you can run a Java process that connects to PostgreSQL and executes SQL files. Every one of those packages is a potential source of CVE findings, and most of them will never be patched in the Flyway image because Redgate doesn’t rebuild on every upstream security release.
For Go-based services, the solution is straightforward: compile a static binary and run it on scratch (literally an empty filesystem). But Flyway is Java. You need a JVM. You need class libraries. You can’t go fully empty.
Enter distroless.
What is Distroless?
Google’s distroless images contain only your application and its runtime dependencies. No package manager. No shell. No ls, cat, or curl. No /etc/passwd with default accounts. Nothing that isn’t strictly necessary to run your program.
For Java, gcr.io/distroless/java11-debian11 provides:
- The OpenJDK 11 JRE (not the full JDK)
- glibc and the minimal native libraries Java needs
- CA certificates for TLS
- A
/tmpdirectory - Nothing else
The nonroot variant additionally runs as a non-root user (UID 65532), which is best practice for any container that doesn’t need privileged access.
The Architecture
The migration from a standard Flyway image to distroless uses a multi-stage Docker build:
Stage 1 pulls the official Flyway image and uses it purely as a source of JAR files. We don’t run anything from this stage — we just extract the artifacts we need.
Stage 2 starts from the distroless Java image and copies in only the Flyway libraries and the PostgreSQL JDBC driver. No other database drivers, no shell scripts, no configuration files we don’t need.
The key insight is that you don’t need the flyway shell script at all. That script just resolves the classpath and calls java -cp ... org.flywaydb.commandline.Main. In distroless, we invoke the JVM directly using Docker’s exec-form ENTRYPOINT.
The Dockerfile
ARG pull_registry=0123456789012.dkr.ecr.us-west-2.amazonaws.com
# Stage 1: Extract Flyway distribution
FROM ${pull_registry}/flyway/flyway:7.15.0 AS flyway-source
# Stage 2: Distroless Java runtime
FROM gcr.io/distroless/java11-debian11:nonroot
COPY --from=flyway-source /flyway/lib /flyway/lib
COPY --from=flyway-source /flyway/drivers/postgresql-42.2.19.jar /flyway/drivers/postgresql-42.2.19.jar
COPY --from=flyway-source /flyway/jars /flyway/jars
COPY migrations /flyway/sql
WORKDIR /flyway
ENTRYPOINT [ \
"/usr/bin/java", \
"-Djava.security.egd=file:/dev/../dev/urandom", \
"-cp", "/flyway/lib/community/*:/flyway/lib/*:/flyway/drivers/*", \
"org.flywaydb.commandline.Main" \
]
CMD ["-locations=filesystem:/flyway/sql", "-connectRetries=5", "migrate"]
Let’s break down the important decisions:
Only copy the PostgreSQL driver. The official image ships with drivers for Oracle, MySQL, MariaDB, SQL Server, SQLite, Derby, H2, Snowflake, Firebird, and more. Each one is dead weight in your image and a potential attack surface. Copy only what you use.
Copy the /flyway/jars directory. This is critical — more on this below.
Use /usr/bin/java from the distroless image. The distroless java11 image ships with OpenJDK 11.0.24 at /usr/bin/java. Don’t copy the JDK from the Flyway image — it’s older (11.0.11) and you’d be duplicating Java runtimes.
Use exec-form ENTRYPOINT. Shell-form (ENTRYPOINT command arg1) requires /bin/sh, which doesn’t exist in distroless. Exec-form (ENTRYPOINT ["binary", "arg1"]) invokes the binary directly via the kernel.
The urandom trick. -Djava.security.egd=file:/dev/../dev/urandom avoids JVM blocking on entropy. The path traversal through /dev/.. is intentional — it bypasses a JVM bug where file:/dev/urandom is silently mapped to /dev/random (blocking) in some JDK versions.
The Environment Variable Shift
The old pattern used shell variable expansion in CMD:
CMD /flyway/flyway \
-url=jdbc:postgresql://${PSQL_HOST}:5432/${PSQL_NAME} \
-user=${PSQL_USER} \
-password=${PSQL_PASSWORD} \
-connectRetries=5 \
migrate
This requires a shell. In distroless, there is no shell. But Flyway natively reads environment variables with the FLYWAY_ prefix:
| Environment Variable | Purpose |
|---|---|
FLYWAY_URL | Full JDBC connection string |
FLYWAY_USER | Database username |
FLYWAY_PASSWORD | Database password |
FLYWAY_CONNECT_RETRIES | Number of connection retry attempts |
FLYWAY_SCHEMAS | Comma-separated schema list |
FLYWAY_LOCATIONS | Migration file locations |
So instead of composing the URL at container runtime via shell interpolation, you compose it at the orchestration layer (ECS task definition, Kubernetes pod spec, docker-compose environment) and pass the full JDBC URL directly.
docker-compose.yml example:
database-migration:
platform: linux/amd64
build:
context: .
dockerfile: Dockerfile-flyway
environment:
FLYWAY_URL: jdbc:postgresql://database:5432/myservice
FLYWAY_USER: myservice
FLYWAY_PASSWORD: myservice
FLYWAY_CONNECT_RETRIES: "5"
The Silent Exit Bug: Why /flyway/jars Matters
During development of this approach, I encountered a maddening issue: Flyway would exit with code 1 and produce absolutely zero output — no stdout, no stderr, nothing. The -? help flag worked. But any command that actually did work (info, migrate, validate) would silently die.
After extensive debugging with -verbose:class JVM tracing, I discovered the root cause: Flyway 7.15’s startup code scans for a jars/ directory relative to its installation path. When that directory doesn’t exist, the FilenameFilter lambda fails, and Flyway calls System.exit(1) without printing any error message.
The fix is trivial:
COPY --from=flyway-source /flyway/jars /flyway/jars
The directory contains nothing but a put-your-jars-here.txt README file. But its existence is required for Flyway to proceed past startup. This is arguably a bug in Flyway 7.15 — a missing optional directory should log a warning, not silently exit — but it’s the reality we have to work with.
If you’re debugging a distroless Flyway container that exits 1 with no output, check this first.
Results
| Metric | Before (flyway:7.15.0) | After (distroless) |
|---|---|---|
| Image size | 1.05 GB | 320 MB |
| OS packages | ~400 | 0 |
| Shell access | Yes (bash, sh) | None |
| Package manager | apt | None |
| Running as | root | nonroot (65532) |
| CVE scan findings (OS) | 30-80+ depending on scan date | 0 |
The image is 70% smaller, runs as non-root by default, and has zero OS-level attack surface for scanners to flag.
Debugging Without a Shell
The obvious trade-off: you cannot docker exec -it container sh into a distroless container. For a migration container that runs for a few seconds and exits, this is rarely a problem. But when things go wrong, you need strategies:
1. Use the debug variant during development:
Replace :nonroot with :debug in your Dockerfile during development. The debug variant adds busybox, giving you a shell at /busybox/sh. Never ship this to production.
2. Read the logs:
Flyway prints detailed migration progress to stdout. In ECS/Kubernetes, these go to CloudWatch/your log aggregator. Add -X to CMD for verbose debug output when troubleshooting.
3. Test locally with docker-compose:
docker compose --profile platform run --rm database-migration
This streams Flyway’s output directly to your terminal.
4. Build a sidecar debug container:
If you need to inspect the filesystem of a distroless container, you can mount the same volume in a debug container:
docker run --rm -it --volumes-from flyway-container alpine sh
Adapting for Different Services
Most Flyway Dockerfiles across a typical organization follow the same pattern. The variations are:
Different migration paths:
# Standard: migrations/ directory at repo root
COPY migrations /flyway/sql
# Subdirectory structure (e.g., migrations/sql/)
COPY migrations/sql /flyway/sql
# With config file
COPY migrations/sql /flyway/sql
COPY migrations/config /flyway/config
# Add to CMD: "-configFiles=/flyway/config/flyway.conf"
Different JDBC drivers:
# PostgreSQL (most common)
COPY --from=flyway-source /flyway/drivers/postgresql-42.2.19.jar /flyway/drivers/
# MySQL
COPY --from=flyway-source /flyway/drivers/mysql-connector-java-8.0.24.jar /flyway/drivers/
# Multiple drivers (avoid if possible)
COPY --from=flyway-source /flyway/drivers/postgresql-42.2.19.jar /flyway/drivers/
COPY --from=flyway-source /flyway/drivers/mysql-connector-java-8.0.24.jar /flyway/drivers/
Redshift (via PostgreSQL driver):
Redshift uses the PostgreSQL wire protocol, so the same PostgreSQL JDBC driver works. Just use the Redshift-specific JDBC URL format: jdbc:redshift://... (which may require the Redshift-specific driver JAR instead).
Platform Considerations
gcr.io/distroless/java11-debian11 only publishes linux/amd64 images. If your developers use Apple Silicon (M1/M2/M3) Macs, they’ll need Rosetta emulation. Add platform: linux/amd64 to your docker-compose service definition to make this explicit. Docker Desktop handles the emulation transparently, though with a small performance overhead for local development.
In CI/CD (running on x86_64 Linux), this is a non-issue.
CI/CD: No Changes Required
If your CI pipeline already builds from Dockerfile-flyway and pushes to a container registry, no pipeline changes are needed. The multi-stage build is transparent — Docker pulls both base images during build and produces a single output image. The only change is to the Dockerfile itself and the runtime environment variable configuration.
Security Scanning Results
After deploying the distroless Flyway image, your vulnerability scanner should report:
- OS-level CVEs: 0 (no OS packages to scan)
- Application-level CVEs: Only those in the Flyway JARs and PostgreSQL JDBC driver themselves, which are Java library vulnerabilities — far fewer and more actionable than OS-level noise
- Compliance findings: No default accounts, no unnecessary services, no shell access, non-root execution
This dramatically improves your signal-to-noise ratio in security findings and reduces the toil of triaging CVEs that have no actual exploit path in a migration container.
Conclusion
Distroless is the Java equivalent of scratch for Go. For Flyway migration containers — which run for seconds, execute SQL, and exit — there’s no reason to carry an entire operating system. The migration is straightforward:
- Multi-stage build to extract Flyway JARs
- Copy onto distroless Java base
- Invoke Java directly (no shell)
- Use Flyway’s native
FLYWAY_*environment variables - Remember the
/flyway/jarsdirectory
The result: 70% smaller images, zero OS-level CVEs, and one less category of security findings to triage every week.