For the Kubernetes management platform, visit portainer.io · For AI solutions, visit portainer.ai
Live webinar, Oct 20 | Git, Lightweight Kubernetes at the Edge, and What's New for Industrial Teams. Save your seat →
How Tos

Build a Container Image:
Primer 002

This is part 2 of our primer series for engineers who run containers on industrial systems. New to the series? Start with the introduction, You Don’t Need to Be a Developer to Run Containers Well, and Git for Non-Developers: Primer 001.

Audience: Engineers who manage container deployments with Portainer and Docker on industrial and critical systems.

Goal: You build an OCI-compatible image from a Dockerfile, test it locally, tag it, and push it to a remote registry. Portainer can then use the image from a stack or a later GitOps workflow.

This primer uses the Go application in nrxlab/opto-portainer-demo as the example. It shows the minimum process needed to build and publish an image. A later primer will cover automatic image builds and GitOps updates from repository changes.

Why build an image at all? Every container deployment runs from an image. You build your own when a published image does not contain your application, when the runtime layout must be fixed inside the artifact, or when your security policy requires a base image that you select and track yourself. A self-built image turns your application and its dependencies into one versioned artifact: the same Dockerfile produces the same result on a laptop, a build server, and a managed industrial device. The registry is then the controlled hand-off point between the system that builds the image and the systems that run it.

1. Image, container, and registry

An image is a versioned package of filesystem layers and metadata. A container is a running process created from an image. A registry stores and distributes images.

Docker image formats follow the Open Container Initiative (OCI) specifications. Docker-compatible registries can store OCI images. The commands in this primer use Docker, but the image can also be used by other OCI-compatible runtimes.

The basic path is:

Image to registry to runtimeA Dockerfile and build context become image layers, then a tagged image. The registry stores and distributes the image. The container runtime runs it as a container.Dockerfile+ build contextImage layersone perinstructionTagged imagename:1.0.0Registrystores+ distributesRuntimecontainerprocessimageregistrycontainer
A Dockerfile and build context become image layers, then a tagged image. The registry stores and distributes the image; the container runtime runs it as a container.

A tag is a readable name such as 1.0.0 or 2026.09.22. A digest is a content address such as sha256:.... Tags can move. A digest identifies one exact image manifest.

Tag versus digestThe tag 1.0.0 previously pointed to image A and now points to image B. The digest of each image is fixed and cannot move.Image Adigest sha256:7f3a…a digest cannot move to another imageImage Bdigest sha256:9c1e…tag: 1.0.0readable namepreviouslynow points here
Tag versus digest: the tag 1.0.0 previously pointed to image A and now points to image B. Each image's digest is fixed and cannot move.

Considerations:

  • Use a release tag for a release that must be traceable.
  • Do not use latest as the only production reference.
  • Record the image digest when the deployed image must be reproducible.
  • Do not put API keys, passwords, certificates, or .env files in the build context.

2. Install and check the local tools

Linux

Install Docker Engine by following the Docker Engine installation guide. Use the installation instructions for the distribution. Do not mix packages from different Docker installation methods.

Windows

Install Docker Desktop and enable the Linux container engine. Use PowerShell or Git Bash for the commands in this primer.

Check the tools:

# Show the Docker client and server versions.
docker version

# Show the Buildx plugin version.
docker buildx version

# Show the active builder and supported platforms.
docker buildx ls

The docker command is the Docker client. buildx uses BuildKit to build images and can publish multi-platform image indexes.

Considerations:

  • On Linux, the current user must have permission to access the Docker daemon.
  • On Windows, Docker Desktop must be running before a build.
  • A local build normally targets the host architecture. Check the target before you publish an image.

3. Get the example project

Clone the example repository:

# Download the example project.
git clone https://github.com/nrxlab/opto-portainer-demo.git

# Enter the project directory.
cd opto-portainer-demo

The build context is the directory passed as the final argument to docker buildx build. In this example, . means the current project directory.

Review the files before you build:

File Purpose
Dockerfile Defines the image build stages and the final runtime process.
go.mod and go.sum Define the Go module and its dependency checksums.
cmd/ Contains the application source.
.env.example Documents runtime settings. It is not a secret store.
compose.yml Defines the example collector and MQTT broker deployment.
.dockerignore Reduces the build context and excludes local files. Add one before a production build.
Shell showing the project git repository being cloned and the directory layout after cloning.
Cloning the example project and listing its files: Dockerfile, compose.yml, go.mod, go.sum, and cmd/.

4. Read the Dockerfile

The example Dockerfile is a multi-stage build:

Note: the blank lines and comments in this example are there to make each step easier to read. Docker ignores them when it builds the image.

# syntax=docker/dockerfile:1

# Build with the native builder platform.
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build

# Declare the target operating system and CPU architecture.
ARG TARGETOS
ARG TARGETARCH

# Set the working directory for build commands.
WORKDIR /src

# Copy dependency files before source files to improve cache reuse.
COPY go.mod go.sum ./

# Download Go dependencies.
RUN go mod download

# Copy the application source.
COPY cmd ./cmd

# Compile a static target binary.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
    go build -trimpath -ldflags='-s -w' \
    -o /out/opto-rio-rest-collector ./cmd/opto-rio-rest-collector

# Start a small runtime image with no package manager or shell.
FROM scratch

# Copy trusted CA certificates from the build stage.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy only the compiled application.
COPY --from=build /out/opto-rio-rest-collector /opto-rio-rest-collector

# Run as a non-root numeric user.
USER 65532:65532

# Start the application.
ENTRYPOINT ["/opto-rio-rest-collector"]

Build stages and layers

The first line, # syntax=docker/dockerfile:1, is a parser directive. It must be the very first line of the Dockerfile, before any other comment, blank line, or instruction. If anything comes before it, BuildKit treats it as an ordinary comment and uses its built-in syntax instead. See the Dockerfile reference for parser directives.

Each FROM starts a stage. Each Dockerfile instruction can create a layer or image configuration change. The final image copies only the required files from the build stage.

Multi-stage buildThe build stage holds the Go toolchain, the application source, the module cache, and a shell. Only the compiled binary and the CA certificates are copied into the empty scratch runtime stage. Everything else is left behind.build stagegolang:1.24-alpine• Go toolchain + compiler• application source (cmd/, go.mod)• module cache• shell + package manager• compiled binary + CA certificates →runtime stagescratch — empty base• compiled binary• CA certificates• USER 65532 (non-root)COPY --from=buildbinary+ certs onlyleft behind: toolchain, source, cache, shellno shell, no package manager
Multi-stage build: only the compiled binary and the CA certificates move from the build stage into the empty scratch runtime stage. The toolchain, source, cache, and shell are left behind.

The builder contains Go and the source toolchain. The scratch runtime contains no shell or package manager. This reduces size and removes many runtime tools, but the application must be compatible with that limitation.

Important Dockerfile instructions

Instruction Meaning and points to think about
FROM Selects a base image and starts a stage. Pin a trusted image by version, and use a digest when the base must be exact.
ARG Defines a build-time value. It is not a safe place for a secret because build metadata and layers can expose values.
ENV Defines a value available when the image runs. Do not use it for secrets.
WORKDIR Sets the directory for later instructions and the runtime process.
COPY Copies files from the build context or another stage. Copy only the files that the stage needs.
RUN Executes a command during the build. Keep package installation and cleanup in a controlled layer.
USER Selects the runtime user. Prefer a non-root user when the application permits it.
ENTRYPOINT Defines the main process. The JSON form avoids an extra shell.
EXPOSE Documents a listening port. It does not publish a host port.
HEALTHCHECK Defines a runtime health test when the image can perform one safely.

Base image choices

  • Vendor or distribution base: golang:1.24-alpine, debian:bookworm-slim, or a supported RHEL-compatible base can provide tools and certificates. Track its support period and security updates.
  • Distroless: contains the application and selected runtime files, but normally no shell or package manager. It is useful when the application needs runtime libraries or certificates but does not need administration tools.
  • Scratch: contains nothing until the Dockerfile copies files into it. It is suitable for a static binary with all required certificates and data copied explicitly.
  • Build from scratch: means starting from scratch, not compiling without dependencies. Verify certificates, time-zone data, dynamic libraries, and user information before use.

Do not select a small base image only for size. Confirm that it receives security updates and that the application has the files it needs.

Build context and secrets

The build context is sent to the builder. Add a .dockerignore file:

.git
.env
*.key
*.pem
bin/
dist/

Use BuildKit secret mounts for a secret that a build step must read. Do not use ARG or ENV for build secrets. The secret should not become part of an image layer.

5. Build and run the image locally

Build a single-platform image for the local machine:

# Build the image from the Dockerfile in the current directory.
# Tag the local image with a test tag.
docker buildx build --load \
  --tag opto-rio-rest-collector:local \
  .
Flag Meaning
buildx build Runs a BuildKit image build.
--load Loads a single-platform result into the local Docker image store.
--tag / -t Assigns an image name and tag.
. Uses the current directory as the build context.

Inspect the result:

# List the local image.
docker image ls opto-rio-rest-collector

# Show image configuration and layer metadata.
docker image inspect opto-rio-rest-collector:local

The collector requires runtime values such as OPTO_HOST and OPTO_API_KEY. Run it only with values supplied outside the image:

# Provide runtime values without writing them into the image.
docker run --rm \
  --env OPTO_HOST="10.0.2.55" \
  --env OPTO_API_KEY="replace-this-value" \
  --env OUTPUT_MODE=stdout \
  opto-rio-rest-collector:local

Do not put a real API key in shell history. Use an environment file with suitable file permissions or a runtime secret mechanism for repeated tests.

6. Port and environment paths

Container networking has separate layers:

external host port → container host interface → container port → application listener

EXPOSE documents a container port. It does not create a host mapping. A runtime or Compose file maps the host port to the container port.

For the example MQTT broker:

services:
  mosquitto:
    ports:
      - "1883:1883"

The first port is the host port. The second port is the container port. A client outside the Docker network uses the host address and port. A service in the same Compose network uses the service name and the container port, such as mosquitto:1883.

Environment values follow a separate path:

Portainer or Compose value → container environment → application configuration → workload behavior

In the example Compose file:

services:
  opto-rio-rest-collector:
    environment:
      OPTO_HOST: ${OPTO_HOST:?set OPTO_HOST}
      OPTO_API_KEY: ${OPTO_API_KEY:?set OPTO_API_KEY}
      OUTPUT_MODE: ${OUTPUT_MODE:-mqtt}
      MQTT_URL: ${MQTT_URL:-tcp://mosquitto:1883}
  • ${NAME:?message} requires a value and stops deployment when it is absent.
  • ${NAME:-default} uses the supplied value or the default.
  • OPTO_API_KEY is passed to the container at runtime. It is not baked into the image.

Portainer environment variables are deployment inputs. The image must read the corresponding names, and the stack definition must pass them to the container. A variable in Portainer does nothing if the Compose service does not pass it through.

Portainer edge stack creation screen showing the imported environment variables.
Create Edge Stack from a Git Source, with the collector's runtime values set under Environment variables.

7. Tag the image for a registry

A registry image name has this form:

[registry-host/]namespace/image:tag

Examples:

ghcr.io/your-user/opto-rio-rest-collector:1.0.0
gitlab.example.com/group/opto-rio-rest-collector:1.0.0
docker.io/your-user/opto-rio-rest-collector:1.0.0
quay.io/your-user/opto-rio-rest-collector:1.0.0

Create a release tag and an optional moving channel tag:

# Create a release tag for the exact source revision.
docker image tag opto-rio-rest-collector:local \
  REGISTRY/NAMESPACE/opto-rio-rest-collector:1.0.0

# Create a separate channel tag only when your change process permits moving tags.
docker image tag opto-rio-rest-collector:local \
  REGISTRY/NAMESPACE/opto-rio-rest-collector:stable

Replace REGISTRY/NAMESPACE with a real path. There must be no space after the registry slash. For example:

# Tag the image for Docker Hub.
docker image tag opto-rio-rest-collector:local \
  docker.io/your-user/opto-rio-rest-collector:1.0.0

A tag changes when you publish another image with the same name. Use the digest returned by the registry for an immutable deployment reference.

8. Authenticate to and push to registries

Use the registry hostname in both the image name and the login command. Keep registry credentials outside the image and repository.

GitHub Container Registry (GHCR)

Install the GitHub CLI and authenticate as described in Primer 001:

# Authenticate the GitHub CLI.
gh auth login

# Configure git credentials for GitHub hosts.
gh auth setup-git

For GHCR, use a token with package write permission. The default GitHub CLI login does not include the write:packages scope, so a push with that token fails. Add the scope to the stored CLI token one time:

# Add the package write scope to the GitHub CLI token (one-time per account).
gh auth refresh -s write:packages

# Confirm the granted scopes.
gh auth status

Then pass the token to Docker through standard input and push:

# Request a token from the authenticated GitHub CLI.
gh auth token | docker login ghcr.io --username YOUR_GITHUB_USER --password-stdin

# Push the release tag.
docker push ghcr.io/YOUR_GITHUB_USER/opto-rio-rest-collector:1.0.0

Considerations:

  • Confirm that the GitHub account can publish to the package namespace. Do not place the token in the command arguments or in a shell script.
  • A personal access token (PAT) with write:packages and read:packages works in place of the CLI token. Use a PAT when the CLI login flow is not suitable, for example on a build server.
  • A package that GHCR creates on the first push is private by default. Readers need read:packages to pull it, or you must change the package visibility in the package settings. Link the package to its source repository when the repository page should show it.
  • Section 11 uses this package as a pull source inside Portainer. Portainer only pulls, so it gets a token with read:packages and no write scope.

GitLab Container Registry

Use the GitLab CLI or a GitLab access token with the required registry scope:

# Authenticate to the GitLab host.
glab auth login --hostname gitlab.example.com --git-protocol https

# On Linux or macOS, configure glab as the Docker credential helper.
glab auth configure-docker

For a direct login, use standard input:

# Read the token without placing it in the command line.
printf '%s' "$GITLAB_TOKEN" | docker login gitlab.example.com \
  --username YOUR_GITLAB_USER --password-stdin

# Push the release tag.
docker push gitlab.example.com/GROUP/opto-rio-rest-collector:1.0.0

Use the registry path shown by the GitLab project. The hostname, group path, and project path must match that project.

Docker Hub

Create a Docker Hub access token. Do not use an account password for automation:

# Log in to Docker Hub with an access token.
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io \
  --username YOUR_DOCKERHUB_USER --password-stdin

# Push the release tag.
docker push docker.io/YOUR_DOCKERHUB_USER/opto-rio-rest-collector:1.0.0

OCI-compatible registries

Quay and other OCI registries use the same docker login, docker tag, and docker push pattern. Read the provider documentation for token scope, hostname, namespace, and retention rules.

Self-hosted registry

Harbor is a common self-hosted registry option. It requires more setup and operational care than the Git repository servers introduced in Primer 001. Plan storage, TLS, access control, vulnerability scanning, backups, and garbage collection before production use.

9. Multi-platform builds

A platform combines an operating system and CPU architecture. Common values are linux/amd64, linux/arm64, and linux/arm/v7. Build for the architectures that the target devices use.

Create and select a Buildx builder when the local default builder does not support the required platforms:

# Create an isolated BuildKit builder and select it.
docker buildx create --name primer002-builder --use

# Inspect supported platforms.
docker buildx inspect --bootstrap

Build and publish an image index for two platforms:

# Build for amd64 and arm64, then push the manifest and platform images.
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag ghcr.io/YOUR_GITHUB_USER/opto-rio-rest-collector:1.0.0 \
  --push \
  .
Option Meaning
--platform Selects the target platforms.
--push Publishes the result directly to the registry. A multi-platform result is not loaded into the classic local image store.
--tag Names the image manifest.

The example Dockerfile builds Go with TARGETOS and TARGETARCH. BUILDPLATFORM prevents the Go compiler stage from running under emulation when a native builder can compile the target binary.

Considerations:

  • Confirm that every base image supports every requested platform.
  • Test each architecture on suitable hardware or emulation before deployment.
  • QEMU emulation can be slower and can hide architecture-specific behavior.
  • Docker Desktop and remote builders can provide different platform support. Read the Docker multi-platform build documentation.
  • Build services can perform multi-platform builds when local builders are not suitable. GitHub Actions, GitLab CI/CD, Docker Build Cloud, and other paid services require separate credentials, runners, and cost controls.
Shell showing a Buildx builder being created and inspected, with its supported platforms and features.
docker buildx inspect --bootstrap showing the selected builder and its supported platforms, followed by a multi-platform build and push.

10. Release tags and future automation

Use a predictable tag policy:

1.0.0       immutable release intent
1.0         compatible minor-release channel, if maintained
stable      approved deployment channel, if maintained
sha-<commit> exact source reference, when supported by the process

Do not overwrite a release tag. If the image changes, publish a new release tag. Record the source commit, Dockerfile revision, base image digest, build platform, image digest, and scan result with the release record.

A later primer will cover a Git repository workflow that builds and pushes the image after an approved source change. This primer stops at a pushed registry image that Portainer or another runtime can consume.

11. Add the registry to Portainer and verify a pull

Portainer must authenticate to a private registry before a target environment can pull from it. Register the registry one time, then reference it from stacks and containers. See the Portainer documentation for registries. Also confirm the GitHub/GHCR visibility setting for the pushed package (image). Packages can also be linked to a repository on GitHub. See GitHub's documentation on working with the container registry.

Add GHCR as a registry

  1. In the Portainer sidebar, select Registries, then Add registry.
  2. Select the GitHub provider. Portainer sets the hostname to ghcr.io.
  3. Enter the GitHub username and a personal access token with the read:packages scope. Portainer only pulls images, so do not give this token write permission.
  4. Select Add registry.
Portainer Create registry form with the GitHub provider selected.
Portainer Registries → Add registry with the GitHub provider selected, showing the username, token, and organization fields.

The same pattern applies to the other registries in section 8. Select the matching provider or Custom registry, enter the hostname, and use a read-only token when the provider supports one.

Verify the pull

  1. Create a test stack or container in the target environment.
  2. Enter the registry-qualified image reference, for example ghcr.io/YOUR_GITHUB_USER/opto-rio-rest-collector:1.0.0, and select the registered GHCR registry for authentication.
  3. Deploy and confirm that the image pulls and the container starts. On the target host, docker image ls shows the pulled image.
Portainer edge stack creation step showing the configured registry selected for authentication.
Edge stack Registry settings with Use Credentials on and the registered GHCR registry selected.

If the pull fails, check three points: the package visibility on GHCR (private by default), the token scope (read:packages), and the registry path. The hostname, namespace, and image name must match the pushed image exactly.

Portainer container list showing the running containers and the images they use.
Portainer Container list showing the collector running from the ghcr.io image reference.

12. Portainer summary

Portainer can use the pushed image in a stack or a GitOps workflow. The stack definition selects the image reference, maps runtime environment variables, and maps host ports to container ports.

The operational chain is:

approved source → image build → registry tag and digest → Portainer stack/workflow → target environment → container process

For industrial and automation systems, keep these controls separate:

  • Git records the Dockerfile and stack definition.
  • The registry records image artifacts and their digests.
  • Portainer records deployment targets, runtime values, and workflow actions.
  • The device reports the resulting container state.
Who records whatGit records the Dockerfile and stack definition. The registry records image artifacts and digests. Portainer records deployment targets, runtime values, and workflow actions. The device reports container state back to Portainer.GitDockerfilestack definitionchange historyRegistryimage artifactstags + digestsPortainerdeploy targetsruntime valuesworkflow actionsDevicecontainerstate reportsbuild/pushimage refdeploystate report
Who records what: Git records the Dockerfile and stack definition, the registry records image artifacts and digests, Portainer records deployment targets and runtime values, and the device reports container state back.
Portainer GitOps workflow view showing the chain from Git source through workflow to the deployment target.
Portainer GitOps Workflows showing the full chain: Git Source, compose artifact, and deployment target.

Command quick reference

Task Command
check Docker docker version
check Buildx docker buildx version
grant GHCR push scope to the GitHub CLI gh auth refresh -s write:packages
build locally docker buildx build --load --tag name:tag .
inspect image docker image inspect name:tag
log in to a registry docker login registry.example.com
tag an image docker image tag source:tag registry/name:tag
push an image docker push registry/name:tag
create a builder docker buildx create --use
build for multiple platforms docker buildx build --platform ... --push ...
More how tos All resources Get a demo