Docker-Only Development: One Tool for Building and Running Everything
When you join a project written in a language you don't know, the first hurdle is usually just getting it to run. Documentation may be missing, incomplete, or outdated, leaving you to figure out which runtime, build tool, and dependencies to install, and in what order. Even experienced developers hit this wall.
Docker can remove that friction. If the project is set up correctly, you can build and run it without installing a single language runtime or package manager on your host machine. You only need Docker. Here's how that works, moving from simple command aliases to fully self-contained build pipelines.
Step One: Alias Commands to Container Runs
The most basic approach is to run individual tool commands through Docker images. Take a Java project using Maven. Instead of installing Java 11 and Maven 3 locally, you can run them in a disposable container:
alias java='docker run -v "$PWD":/home -w /home openjdk:11-jre-slim java'
alias mvn='docker run -it --rm --name maven -v "$(pwd)":/usr/src/mymaven -w /usr/src/mymaven maven:3-jdk-11-slim mvn'
This lets you execute mvn package, java -version, or any other tool command without the underlying software being present on your machine. The official image documentation usually shows the exact invocation, so you can create a shell alias from it.
Pros
- Removing the image cleans up everything, with no leftover installs on your host.
- Switching tool versions is as simple as changing the image tag.
Cons
- You must still discover which language version and build tool the project needs.
- If the language is unfamiliar to you, that research takes even longer.
- You still need to know the exact commands to run, like
mvn dependency:copy-dependenciesormvn -Dmaven.test.skip=true package.
This method works when you know what you're doing, but it doesn't encode that knowledge in the project itself.
Step Two: Codify the Runtime in a Dockerfile
The Dockerfile takes the next step by describing not just a command, but the application's runtime environment.
FROM openjdk:11-jre-slim
ARG JAR_FILE=target/*.jar
ADD ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Building the image with docker build -t my-application . shows the issue with this approach: it assumes a JAR file already exists. The Dockerfile handles the runtime, but the build step—compiling the source, resolving dependencies—still happens on your host. In a different language or with a different build system, you'd face the same discovery problem again.
Pros
- To run the app, you just need the Dockerfile that ships with the project.
- The Dockerfile explicitly documents the base image and version used.
- It can inherit the benefits of Step One if you also alias the Docker build command.
Cons
- You still need to figure out how to produce the artifact the image expects.
- The specific build commands remain an external piece of knowledge.
Step Three: Multi-Stage Builds for Complete Isolation
Multi-stage builds solve the artifact problem by embedding the entire build process inside the Dockerfile itself. Multiple FROM statements create separate stages, and you selectively copy artifacts between them.
# ============= DEPENDENCY + BUILD ===========================
# Download the dependencies on container and build application
# ============================================================
FROM maven:3-jdk-11-slim AS builder
COPY ./pom.xml /app/pom.xml
COPY . /app
WORKDIR /app
RUN mvn package $MAVEN_CLI_OPTS -Dmaven.test.skip=true
# ============= DOCKER IMAGE ================
# Prepare container image with application artifacts
# ===========================================
FROM openjdk:11-jre-slim
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Here, the first stage, named builder, uses the maven:3-jdk-11-slim image. It copies the project files and runs the Maven package command. The final stage switches to the lighter openjdk:11-jre-slim image. It copies the generated jar from the builder stage, using the stage name as the source location.
The build command stays the same: docker build -t my-application .. The Dockerfile now handles everything from dependency resolution to code compilation to runtime setup.
Pros
- A single
docker buildcommand is all that's needed, regardless of the underlying language toolchain. - You only need Docker installed; no runtimes or build tools on the host.
- This approach inherits the clarity benefits of the previous levels.
You can also plug this Dockerfile into Docker Compose for services needing ports, volumes, or dependencies on other containers.
Breaking the Build Into Logical Stages
Multi-stage builds aren't limited to a single build step. You can model each major phase of development—dependency resolution, testing, packaging—as its own stage.
# ============= DEPENDENCY RESOLVER =============
# Download the dependencies on container
# ===============================================
FROM maven:3-jdk-11-slim AS dependency_resolver
# Download all library dependencies
COPY ./pom.xml /app/pom.xml
WORKDIR /app
RUN mvn dependency:copy-dependencies $MAVEN_CLI_OPTS
# ============= TESTING =================
# Run tests on container
# =======================================
FROM dependency_resolver AS tester
WORKDIR /app
CMD mvn clean test $MAVEN_CLI_OPTS
# ============= BUILDER =================
# Build the artifact on container
# =======================================
FROM dependency_resolver as builder
# Build application
COPY . /app
RUN mvn package $MAVEN_CLI_OPTS -Dmaven.test.skip=true
# ============= DOCKER IMAGE ================
# Prepare container image with application artifacts
# ===========================================
FROM openjdk:11-jre-slim
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
This Dockerfile defines four stages: dependency_resolver, tester, builder, and the final application image. The tester and builder stages both start from the dependency_resolver stage, guaranteeing they use the same resolved dependency tree.
Which stages run by default?
Running docker build -t my-application . only executes dependency_resolver, builder, and the final stage. Docker analyzes the dependency chain backward from the final image: it needs the COPY from builder, and builder needs the FROM from dependency_resolver. The tester stage is not in that chain, so it's skipped.
Reaching the test stage
To run tests explicitly, target the stage with the --target flag:
docker build --target tester -t my-application .
This flag is also supported in Docker Compose's build configuration, allowing you to run tests as part of a compose-managed workflow.
The general principle holds regardless of the language: with the build steps expressed as Docker stages, projects become host-agnostic. The same approach applies to other container runtimes, such as Podman, not just Docker itself.



