Image Build #

After we understand how to prepare hosts and deploy containers, the next crucial step in the containerization lifecycle is thinking about how our application images are built. Many IT teams are stuck in an old pattern: they download raw distro images like Ubuntu or Alpine from the internet, run them as containers, then enter those containers using a terminal SSH session to manually install application libraries. This imperative pattern violates the entire essence of containerization technology.

In the modern production ecosystem, we must adopt the immutable infrastructure principle. Every application update must produce a complete, sealed image ready to be deployed in any environment without requiring additional runtime configuration. Ansible offers full automation capabilities to manage the building, tagging, pushing to a private registry, and periodic cache cleanup on build machines. This allows us to integrate container build pipelines directly into our automation playbooks very cleanly and consistently.

flowchart TD
    Start["Start Build Pipeline"] --> Checkout["Git Checkout Codebase"]
    Checkout --> GetVars["Fetch Version & Git Commit Hash"]
    GetVars --> Login["Login to Private Registry"]
    Login --> BuildImage["Build Image (Multi-Stage & Cache)"]
    BuildImage --> TagImage["Apply Tags (SemVer & Git Commit)"]
    TagImage --> PushRegistry["Push Image to Registry"]
    PushRegistry --> Logout["Logout from Registry"]
    Logout --> CleanupCache["Run Docker System Prune (Cleanup)"]
    CleanupCache --> End["Done (Image Ready to Deploy)"]

Containerization Philosophy: Why Images Must Be Immutable #

The basic principle of container deployment is that containers must be disposable — meaning containers can be stopped, removed, and recreated at any time without causing data or application configuration loss. To achieve this, container images must be immutable (unchangeable once declared).

When we deploy an application, we must not do dynamic configuration (like installing additional OS packages or compiling code) inside a running container. If we do, those changes disappear instantly when the container crashes or is restarted by the system.

Here’s a comparison table showing the difference between the mutable approach (old pattern) and immutable (containerization best practice):

Maintenance AspectMutable Approach (Modifying Running Containers)Immutable Approach (Building New Images)
Code UpdatesCode pulled using Git pull directly inside the active container.Code compiled on the build machine, then wrapped into a new image with a unique version tag.
OS Package InstallationRunning apt-get install commands inside the running container.All dependencies declared in the Dockerfile and installed during the build process.
ReproducibilityVery low. Hard to guarantee a new container will be identical to the old one when redeployed.Absolute. The same image is guaranteed to run with the same behavior on dev, staging, and prod servers.
Rollback MethodMust undo manual code changes and reinstall modified packages.Just switch the container image tag to the previous version (for example from v1.2.0 to v1.1.0).
Security Audit TrailHard to audit because system changes aren’t recorded in a central configuration repository.Very transparent because all change steps are written in the Dockerfile and git playbooks.

Building Images Using the community.docker.docker_image Module #

To automate image creation from Dockerfile files, we use the community.docker.docker_image module with the source: build parameter. This module supports build argument configuration (build-args), cache usage control, and automatic pulling of the latest base image before the compilation process starts.

Here’s an Ansible task for automatically building a Node.js application image:

# playbooks/tasks/build_image.yml
---
- name: Build the Docker image for the internal application
  community.docker.docker_image:
    name: "registry.company.com/core-app"
    tag: "{{ app_version }}"
    source: build
    build:
      path: "/opt/build_workspace/app" # Working directory containing the Dockerfile
      dockerfile: "Dockerfile" # Dockerfile file name (default)
      args:
        NODE_ENV: "production"
        API_URL: "https://api.company.com"
        BUILD_ID: "{{ ansible_date_time.epoch }}"
      pull: true # Always pull the latest base image (e.g. node:alpine) before build
      nocache: false # Use build cache if there are no Dockerfile instruction changes
    state: present
  register: build_result

- name: Display the unique ID of the successfully built image
  debug:
    msg: "New image ID: {{ build_result.image.Id }}"

Optimizing Image Size with Multi-Stage Builds #

When building images for production environments, we must pay attention to the build result file size. Images that are too large (reaching hundreds of megabytes to gigabytes) consume network bandwidth when pushed/pulled and slow down application scaling on target servers.

Why Do We Need Multi-Stage Builds? #

Many applications need compilation tools (compilers), system header files, or complete SDKs to build binary code (for example Node.js development tools, Go compiler, or Java JDK). However, after the binary files are successfully built, we no longer need those compilers to run the application.

With the multi-stage build technique, we divide the Dockerfile into several stages. The first stage builds the application (using a complete, heavy build image), then the second stage copies the built binary files into a very lightweight runtime image (like alpine or distroless), leaving all the compiler garbage behind.

Example Dockerfile Anti-Pattern vs Multi-Stage Solution #

Let’s look at the efficiency difference in the following Dockerfile writing:

# ANTI-PATTERN: Combining build and runtime environments in one stage (Very heavy image)
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install  # Downloads large devDependencies
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

# CORRECT: Separating build and runtime stages using Multi-Stage (Very lightweight image)
# --- Stage 1: Application Builder ---
FROM node:18-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci  # Install dependencies cleanly for the build
COPY . .
RUN npm run build

# --- Stage 2: Application Runtime ---
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --only=production  # Only download production dependencies
# Copying compiled files from the builder stage
COPY --from=builder /build/dist ./dist
EXPOSE 3000
USER node  # Run the container as a non-root user for security
CMD ["node", "dist/main.js"]

When Ansible executes the build process with the multi-stage Dockerfile above, the resulting image on the target server shrinks by up to 80%, saving host disk space capacity and significantly improving container start speed.


Authentication and Pushing to a Private Registry #

After the image is successfully built on the build machine, the next step is distributing it to production servers. We must not publish internal company applications to the public Docker Hub. We must use a private registry (like GitLab Container Registry, AWS ECR, GCP Artifact Registry, or Harbor).

Before pushing the image, our playbook must securely log in to that registry, do the push, and immediately remove the login information (logout) from the build host after the process finishes to prevent authentication token theft.

# playbooks/tasks/push_image.yml
---
- name: Login to the private Docker registry
  community.docker.docker_login:
    registry_url: "registry.company.com"
    username: "{{ vault_registry_username }}"
    password: "{{ vault_registry_password }}"
  no_log: true # Keep credential log output secret

- name: Push the specific version image to the registry
  community.docker.docker_image:
    name: "registry.company.com/core-app"
    tag: "{{ app_version }}"
    push: true
    source: local # Use the newly built local image
  register: push_result

- name: Remove registry authentication from the build machine (logout)
  community.docker.docker_login:
    registry_url: "registry.company.com"
    state: absent
  no_log: true

Tagging Strategy for Production Security #

One of the most common operational mistakes in production is using the latest tag for application deployments.

# ANTI-PATTERN: Deploying a container with the latest tag
- name: Deploy the container the wrong way
  community.docker.docker_container:
    name: web-app
    image: "registry.company.com/core-app:latest" # DON'T DO THIS IN PRODUCTION

Why is the latest tag dangerous?

  1. Code Uncertainty: The latest tag is mutable. We can’t know for sure which git commit code is actually active inside the container right now.
  2. Failed Rollback: If we want to roll back because of a bug in the latest deployment, we can’t point back to the previous version because the previous version was also once tagged as latest.
  3. Host Cache Problem: The Docker daemon by default won’t pull a new image from the registry if the latest tag already exists on the local host, unless we force the force_source: true parameter.

Practical Solution: Use a Version and Commit Hash Combination Tag #

A good tagging strategy combines the formal release version (Semantic Versioning) with the active Git commit hash snippet during the build. This guarantees full traceability from the container back to the source code in the Git repository.

Here’s an Ansible task for dynamically generating that combination tag:

# playbooks/tasks/tagging_strategy.yml
---
- name: Fetch the first 7 characters of the local git commit hash
  command: git rev-parse --short HEAD
  delegate_to: localhost
  register: git_commit_hash
  changed_when: false

- name: Build the image with a specific release tag
  community.docker.docker_image:
    name: "registry.company.com/core-app"
    tag: "{{ app_version }}-{{ git_commit_hash.stdout }}"
    source: build
    build:
      path: "/opt/build_workspace/app"
    state: present

- name: Create a shadow tag as a latest alias for dev needs
  community.docker.docker_image:
    name: "registry.company.com/core-app"
    repository: "registry.company.com/core-app"
    tag: "latest"
    source: local
    force_tag: true # Force overwriting the old latest tag on the local machine

With this tactic, when deploying to production we use a unique tag like v2.4.0-a3f5c1d, so the operations team knows exactly which code lines are running on the servers.


Host Disk Maintenance and Build Cache Cleanup #

Repeatedly building Docker images on CI/CD machines or control nodes accumulates a huge amount of garbage capacity. Every Dockerfile instruction produces a new cache layer. If the build cache isn’t cleaned, our build server disk fills up within days.

We must configure automatic cleanup tasks at the end of the build playbook to remove orphaned (dangling) images and outdated build caches.

# playbooks/tasks/build_cleanup.yml
---
- name: Remove dangling (untagged) images left during builds
  community.docker.docker_prune:
    images: true
    images_filters:
      dangling: true
  register: prune_images_result

- name: Clean up outdated Docker build caches (older than 48 hours)
  command: docker builder prune --filter "until=48h" -f
  register: prune_cache_result
  changed_when: "'reclaimed space' in prune_cache_result.stdout"

- name: Report the disk space freed on the build server
  debug:
    msg:
      - "Image capacity freed: {{ prune_images_result.space_reclaimed | default(0) | filesizeformat }}"
      - "Cache cleanup status: {{ prune_cache_result.stdout_lines | last | default('No data') }}"

Using BuildKit for Optimal Build Speed #

By default, Docker build machines use the old parser that processes Dockerfile instructions linearly (one by one in sequence) and slowly. To speed up compilation time on production servers or CI/CD machines, we must enable Docker BuildKit.

BuildKit is Docker’s modern builder architecture offering various advantages:

  • Parallel Execution: Can analyze Dockerfile dependency graphs and build non-dependent stages simultaneously.
  • Smart Cache Storage: Stores build cache granularly at the compiler level, not just comparing Dockerfile text strings.
  • Secure Mounts (Secret Mounts): Allows us to inject temporary SSH keys or secret credentials during the build without recording them in the final image layers.

We can enable BuildKit when calling the Ansible module by setting the DOCKER_BUILDKIT: 1 environment variable at the task level:

# playbooks/tasks/build_with_buildkit.yml
---
- name: Build the image using Docker BuildKit
  community.docker.docker_image:
    name: "registry.company.com/secure-app"
    tag: "{{ app_version }}"
    source: build
    build:
      path: "/opt/workspace"
      # Using the BuildKit backend for cache optimization
      nocache: false
    state: present
  environment:
    DOCKER_BUILDKIT: "1" # Enabling the BuildKit engine

If we combine BuildKit with multi-stage Dockerfiles, BuildKit automatically skips build stages that don’t directly contribute to the final target, dramatically saving build time by up to 70% on subsequent builds.


Integrated Image Security Testing (Vulnerability Scanning) #

Building images quickly is useless if those images contain critical security vulnerabilities in OS libraries or the npm/pip packages installed inside them. As part of devops best practices, we must security scan newly built images before allowing them to be pushed to the private registry.

We can integrate open-source scanning tools like Trivy directly into our Ansible playbook flow. If CRITICAL level vulnerabilities are detected, Ansible automatically fails the process and cancels the push to the registry.

# playbooks/tasks/scan_and_push.yml
---
- name: 1. Build the local image for testing
  community.docker.docker_image:
    name: "local-test/app"
    tag: "latest"
    source: build
    build:
      path: "/opt/workspace"
    state: present

- name: 2. Run the vulnerability scan using the Trivy CLI
  command: "trivy image --severity HIGH,CRITICAL --exit-code 1 local-test/app:latest"
  register: scan_result
  failed_when: scan_result.rc != 0
  changed_when: false
  ignore_errors: false # Don't ignore if critical findings exist

- name: 3. Apply the official tag after passing the security tests
  community.docker.docker_image:
    name: "local-test/app"
    repository: "registry.company.com/secure-app"
    tag: "{{ app_version }}"
    source: local
    state: present

- name: 4. Push the secure image to the registry
  community.docker.docker_image:
    name: "registry.company.com/secure-app"
    tag: "{{ app_version }}"
    push: true
    source: local

With this flow, we guarantee that every image successfully distributed to the registry is an image tested free of high-risk security vulnerabilities, keeping our production cluster safe from exploit threats.


Summary #

  • Adopt the Immutability Principle — Never do imperative configuration modifications or code updates inside running production containers.
  • Use Multi-Stage Builds — Apply the separation of the build environment (builder) and runtime environment (runner) in the Dockerfile to produce the smallest possible image size.
  • Registry Authentication Security — Use the docker_login module to securely log into private registries with the no_log: true option, and always clean up credentials (state: absent) after the push process finishes.
  • Avoid the latest Tag in Production — Use unique image tagging with a combination of the application version number (SemVer) and Git commit hash (for example v1.2.0-a1b2c3d) for full traceability.
  • Periodic Build Cache Cleanup — Run the docker_prune module and docker builder prune command on a schedule on build servers so disk storage space doesn’t run out from leftover old build layers.

← Previous: Ansible vs Docker Compose Next: What is Kubernetes? →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact