Building and Publishing Multi-Architecture Docker Images to Nexus (Manually, Step by Step)

Introduction

Modern infrastructure is no longer homogeneous. It is increasingly common to run workloads across different CPU architectures — from traditional x86_64 servers to ARM-based environments such as Apple Silicon or cloud-native ARM instances.

In this article, we walk through a fully manual, transparent, and production-grade approach to:

  • Creating a Docker registry repository in Nexus
  • Building architecture-specific images
  • Publishing them independently
  • Assembling a multi-architecture image using Docker manifests
  • Validating behavior across different platforms

This is not just a “how-to”. It is an exploration of how multi-architecture images actually work under the hood.


1. Nexus Repository Setup

We start by creating a Docker repository in Nexus.

Configuration

Format: docker (hosted)
Repository Connectors: Path based routing
Deployment policy: Disable redeploy

Repository URL:

https://nexus.devops-db.internal/repository/devops_images/

Why these settings?

  • docker (hosted)
    Required to allow pushing custom images. Proxy repositories cannot accept uploads.
  • Path-based routing
    Allows us to use a structured naming convention:<host>/<repository>/<image>:<tag>
  • Disable redeploy
    Prevents overwriting existing tags, enforcing immutability — a critical best practice for reproducibility.


2. Project Structure

The application is a Go service:

.
├── blueprints.go
├── database.go
├── docker
│   └── Dockerfile
├── docs
│   ├── docs.go
│   ├── swagger.json
│   └── swagger.yaml
├── gitlab_tags.go
├── go.mod
├── go.sum
├── main.go
├── models.go
└── roots.pem

Notably:

  • Dockerfile defines a multi-stage build
  • roots.pem contains internal CA certificates required for secure communication

3. Preparing for Multi-Architecture Builds

The Problem

By default, Go builds binaries for a single architecture:

GOARCH=amd64

This hardcodes the build target and breaks portability.

The Solution

We replace it with:

ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -o devops-api

Why this works

  • TARGETARCH is injected automatically by Docker Buildx
  • It allows the same Dockerfile to produce binaries for multiple architectures
  • This keeps the build deterministic and portable

Example:

# build stage
FROM golang:1.26-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -o devops-api

# runtime stage
FROM nexus.devops-db.internal/base_images/base_go:1.0.4

WORKDIR /app

RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY roots.pem /usr/local/share/ca-certificates/roots.crt
RUN update-ca-certificates

COPY --from=builder /app/devops-api .

EXPOSE 8080

CMD ["./devops-api"]

4. Buildx and Builder Configuration

We initialize a builder:

docker buildx rm multiarch-builder
docker buildx create --use --driver docker
docker buildx inspect --bootstrap

Why Buildx?

Standard Docker builds:

  • support only one architecture per image

Buildx:

  • enables cross-platform builds
  • integrates with QEMU for emulation
  • exposes architecture-aware variables like TARGETARCH

5. Building Images (Manual Multi-Arch Approach)

Instead of using automatic multi-platform builds, we deliberately split the process.

The full path nexus.devops-db.internal/devops_images explicitly targets the Docker repository namespace inside Nexus, not just the host. Without including the repository name (devops_images), requests are routed incorrectly, leading to failed pushes or unsupported API operations.

https://nexus.devops-db.internal/repository/devops_images/

Build AMD64

docker buildx build \
  --platform linux/amd64 \
  -f docker/Dockerfile \
  -t nexus.devops-db.internal/devops_images/devops-api:1.1.3-amd64 \
  --load \
  .

Build ARM64

docker buildx build \
  --platform linux/arm64 \
  -f docker/Dockerfile \
  -t nexus.devops-db.internal/devops_images/devops-api:1.1.3-arm64 \
  --load \
  .

Why --load?

  • Loads the image into the local Docker daemon
  • Enables manual control over push operations
  • Avoids automatic registry interaction

6. Validating the Builds

Before pushing, we validate architecture correctness:

docker image inspect nexus.devops-db.internal/devops_images/devops-api:1.1.3-amd64 | grep Architecture

"Architecture": "amd64"
docker image inspect nexus.devops-db.internal/devops_images/devops-api:1.1.3-arm64 | grep Architecture

"Architecture": "arm64"

Why this matters

This confirms:

  • the binary inside the container matches the intended architecture
  • the build process is working correctly

7. Pushing Images (Individually)

We now push each architecture as an independent image:

docker push nexus.devops-db.internal/devops_images/devops-api:1.1.3-amd64
docker push nexus.devops-db.internal/devops_images/devops-api:1.1.3-arm64

Why separate pushes?

Because:

  • Docker images are inherently single-architecture
  • Multi-arch support is achieved via manifests, not image layering

8. Creating the Multi-Architecture Manifest

Now we assemble the final image:

docker manifest create \
  nexus.devops-db.internal/devops_images/devops-api:1.1.3 \
  nexus.devops-db.internal/devops_images/devops-api:1.1.3-amd64 \
  nexus.devops-db.internal/devops_images/devops-api:1.1.3-arm64

Then push it:

docker manifest push nexus.devops-db.internal/devops_images/devops-api:1.1.3

What is a Manifest?

A Docker manifest is:

A pointer that maps a single image tag to multiple architecture-specific images

Conceptually:

devops-api:1.1.3
 ├── amd64 → devops-api:1.1.3-amd64
 └── arm64 → devops-api:1.1.3-arm64


9. Testing Across Architectures

On macOS (ARM) Or AWS EKS ARM

docker pull nexus.devops-db.internal/devops_images/devops-api:1.1.3
docker run --rm -p 8080:8080 nexus.devops-db.internal/devops_images/devops-api:1.1.3

Validate:

docker inspect nexus.devops-db.internal/devops_images/devops-api:1.1.3 | grep Architecture
"Architecture": "arm64"

On Debian (AMD64)

docker pull nexus.devops-db.internal/devops_images/devops-api:1.1.3
docker run --rm -p 8080:8080 nexus.devops-db.internal/devops_images/devops-api:1.1.3
"Architecture": "amd64"

Why this works

When pulling an image:

  1. Docker queries the registry
  2. Retrieves the manifest
  3. Selects the correct image for the host architecture
  4. Pulls only that variant

This process is completely transparent to the user

Leave a Reply

Your email address will not be published. Required fields are marked *