Every QA engineer knows the frustration of slow test environments and bloated containers that take forever to build and deploy. What if you could cut your test image size by 70% and speed up your CI pipeline without sacrificing test quality? Enter Docker multi-stage builds - a feature that's been around since Docker 17.05 but remains surprisingly underutilized in QA automation workflows.
Traditional Docker-based test environments often suffer from being unnecessarily large because they contain:
This bloat causes several issues:
Multi-stage builds allow us to use multiple FROM statements in our Dockerfile. Each FROM instruction can use a different base image, and begins a new stage of the build. We can selectively copy artifacts from one stage to another, leaving behind everything we don't need.
This is perfect for QA automation because we can:
Let's create a multi-stage Dockerfile for a Python-based web application testing environment that uses Selenium WebDriver and Chrome.
This first stage uses a full Python image as its base. We name this stage "builder" using AS builder, which allows us to reference it later in the Dockerfile. We set up a working directory at /build and copy only the requirements.txt file (not the whole project) into the container. Then we use pip's wheel command to pre-build all Python dependencies into wheel files, which are stored in the /build/wheels directory. The --no-cache-dir and --no-deps flags help create minimal wheel packages. The purpose of this stage is purely to build Python packages once, so we don't need to rebuild them in later stages.
FROM python:3.11 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /build/wheels -r requirements.txt
This second stage starts fresh with a slim Debian image and is named "chrome". Here we install the minimal utilities needed to download and install Chrome. The process begins with installing essential tools: wget for downloading files, gnupg for cryptographic operations, ca-certificates for secure connections, and unzip for extracting compressed files. We use the --no-install-recommends flag to prevent unnecessary packages from being installed.
After installing these prerequisites, we download Google's official signing key and process it using GPG to create a proper keyring file. This follows modern Debian security practices by storing the key in /usr/share/keyrings/ rather than using the deprecated apt-key command. We then configure the package repository with a secure reference to this keyring file using the signed-by attribute in the sources list.
Next, we update the package lists and install Chrome with the --no-install-recommends flag again to keep the installation minimal. Finally, we clean up by removing the apt package lists directory, which reduces the image size by eliminating cached package metadata that's no longer needed after installation. The entire process is chained together with && operators to create a single Docker layer, which is more efficient than having separate RUN commands.
FROM debian:bullseye-slim AS chrome
RUN apt-get update && apt-get install -y \
wget \
gnupg \
ca-certificates \
unzip \
--no-install-recommends && \
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /usr/share/keyrings/google-linux-signing-key.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux-signing-key.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update && \
apt-get install -y google-chrome-stable --no-install-recommends && \
rm -rf /var/lib/apt/lists/*
In the following section, we determine the latest compatible ChromeDriver version by querying the LATEST_RELEASE endpoint. We dynamically capture this version number in the CHROME_DRIVER_VERSION variable. We then download the corresponding ChromeDriver zip file quietly and save it to the temporary directory. After extraction to /usr/bin/, we remove the zip file to save space and make the ChromeDriver executable with the chmod command. This approach ensures we have a compatible Chrome and ChromeDriver pair without hardcoding version numbers that might become outdated.
RUN CHROME_DRIVER_VERSION=$(wget -qO- https://chromedriver.storage.googleapis.com/LATEST_RELEASE) \
&& wget -q --no-verbose -O /tmp/chromedriver.zip https://chromedriver.storage.googleapis.com/$CHROME_DRIVER_VERSION/chromedriver_linux64.zip \
&& unzip /tmp/chromedriver.zip -d /usr/bin/ \
&& rm /tmp/chromedriver.zip \
&& chmod +x /usr/bin/chromedriver
Now we begin the final stage with a slim Python image. We create a working directory at /app and use COPY --from=chrome to selectively pull only the necessary Chrome files from the previous stage. This is where multi-stage builds truly shine: we don't bring along all the build tools and dependencies used to install Chrome. This approach allows us to get Chrome and ChromeDriver without all the baggage from their installation process.
FROM python:3.11-slim
WORKDIR /app
COPY --from=chrome /opt/google/chrome /opt/google/chrome
COPY --from=chrome /usr/bin/chromedriver /usr/bin/chromedriver
COPY --from=chrome /usr/bin/google-chrome-stable /usr/bin/google-chrome-stable
Next, we install only the minimum runtime libraries that Chrome needs to run. These are shared libraries that Chrome requires at runtime to handle various system-level operations like rendering and input handling. We clean up apt's cache with rm -rf /var/lib/apt/lists/* to reduce the image size. Again, we use --no-install-recommends to keep things lean. This focused approach to dependencies is much more efficient than simply installing Chrome in the final image, as we would get many unnecessary packages that aren't needed for our testing environment.
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libnss3 \
libx11-6 \
libx11-xcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
fonts-liberation \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
After that, we copy the pre-built Python wheels from our first "builder" stage and the requirements.txt file again (needed for reference). We install the wheels with pip, which is much faster than building packages from source. We then clean up by removing the wheels and requirements.txt after installation. This approach lets us install Python packages without needing compilers or development headers, which significantly reduces image size and improves build times.
COPY --from=builder /build/wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache /wheels/* \
&& rm -rf /wheels \
&& rm requirements.txt
Finally, we copy only the test files and pytest configuration, not the entire project codebase. This selective copying further reduces the image size and keeps it focused on testing. We set environment variables to configure Chrome in headless mode with appropriate Docker settings that allow it to run without display or sandbox requirements. We set the default command to run pytest on our tests directory with verbose output. This final configuration keeps the focus on testing and eliminates any code or files not needed for test execution.
COPY tests/ /app/tests/
COPY conftest.py /app/
ENV CHROME_OPTIONS="--headless --no-sandbox --disable-dev-shm-usage"
CMD ["pytest", "tests/", "-v"]
The multi-stage build approach transforms how we create Docker containers for QA automation. Our tests now run in images approximately 70% smaller than traditional single-stage builds, dramatically reducing storage requirements and network transfer times. This size reduction directly translates to faster startup times in CI/CD pipelines, allowing teams to get feedback more quickly and iterate more rapidly.
Security improves considerably as we eliminate unnecessary build tools and packages from the final image. Each component removed represents one less potential vulnerability, creating a more secure testing environment with a significantly reduced attack surface. The streamlined container also provides a cleaner, more production-like environment for tests, minimizing the frustrating "works on my machine" problems that plague testing efforts.
Docker's intelligent caching mechanism works exceptionally well with multi-stage builds. Each stage can be cached independently, meaning changes to test code don't trigger time-consuming rebuilds of dependencies or browser installations. When we modify our tests, only the final stage needs rebuilding, saving valuable development time.
To maximize these benefits, we should organize our testing code thoughtfully. Let's separate our tests from test utilities and support files to allow for more efficient copying into the final image. We should always use specific versions of base images rather than the "latest" tag to ensure reproducible builds across environments and time. This versioning strategy prevents unexpected changes from breaking our testing infrastructure.
Let's take time to analyze what artifacts actually need to be present at runtime. Often, source code or intermediate build products aren't necessary for test execution and can be left behind. We should create a comprehensive .dockerignore file to prevent copying unnecessary files during the build process, further streamlining our images. For advanced scenarios, we can leverage build arguments to create parameterized builds for different test environments, allowing a single Dockerfile to generate containers for various browsers or test configurations as needed.
Multi-stage Docker builds may not be the newest feature, but they're certainly an underutilized secret weapon in the QA automation arsenal. By thoughtfully separating build and test concerns, we can create leaner, faster, and more reliable test environments that will make both our developers and our finance department happy.
The complete Dockerfile is available on our GitHub page for easy access and experimentation, allowing you to immediately implement this efficient multi-stage build approach in your own QA automation projects. Give it a try!