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-dependencies or mvn -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 build command 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.