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 Aspect | Mutable Approach (Modifying Running Containers) | Immutable Approach (Building New Images) |
|---|---|---|
| Code Updates | Code 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 Installation | Running apt-get install commands inside the running container. | All dependencies declared in the Dockerfile and installed during the build process. |
| Reproducibility | Very 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 Method | Must 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 Trail | Hard 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?
- Code Uncertainty: The
latesttag is mutable. We can’t know for sure which git commit code is actually active inside the container right now. - 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. - Host Cache Problem: The Docker daemon by default won’t pull a new image from the registry if the
latesttag already exists on the local host, unless we force theforce_source: trueparameter.
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_loginmodule to securely log into private registries with theno_log: trueoption, 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_prunemodule anddocker builder prunecommand 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? →