Build, Tag, and Push a Custom Docker Image With Multi-Stage Builds

Package your app into a leaner image, keep build clutter out of production, and ship a versioned container you can test locally before a server ever pulls it.

Docker imagesMulti-stage buildsRegistry workflow
What you learn

How to write a multi-stage Dockerfile, trim build context with .dockerignore, tag images clearly, test them locally, push to a registry, and verify a redeploy.

Good for

Self-hosted web apps, APIs, static sites, and small services you want to deploy consistently across environments.

Risk to watch

Large sloppy images, floating tags like latest, and untested builds create deployment surprises that are hard to diagnose later.

Before you begin

  • Docker installed locally.
  • An app with a working local build process.
  • An account on a container registry such as Docker Hub, GitHub Container Registry, or another OCI-compatible registry.
  • Permission to push images to a namespace like yourname/myapp or ghcr.io/yourorg/myapp.

This guide stays focused on the practical image workflow itself. It does not dive into CI pipelines, release automation, or Kubernetes. The aim is a clean manual process you understand end to end first.

Expected outcome: You will finish with a versioned image in a registry, a locally tested container, and a simple verification path for the server that pulls it.

Step 1: Understand why multi-stage builds are worth it

Many apps need compilers, package managers, or build tools during image creation, but not at runtime. A multi-stage build lets you use one stage for building and another for the final runtime image. That usually gives you:

  • Smaller final images
  • Less attack surface
  • Faster pulls on the server
  • Cleaner separation between build dependencies and runtime files

For example, a Node app might need the full dependency tree and build toolchain to compile assets, but only the built output and production dependencies in the final container.

Step 2: Create a useful .dockerignore

Before touching the Dockerfile, reduce the build context. Docker sends the build context to the daemon, and large noisy directories slow builds and accidentally leak junk into layers.

Create .dockerignore in your project root:

node_modules
.git
.gitignore
Dockerfile*
.dockerignore
.env
.env.*
coverage
dist
build
*.log
.tmp
.cache

Adjust this list for your stack. The rule of thumb is simple: ignore anything that should not be copied into the image build context.

Verification: Run docker build . once and watch the “transferring context” size. If it is unexpectedly large, your .dockerignore probably needs more work.

Step 3: Write a multi-stage Dockerfile

Here is a practical Node example:

# syntax=docker/dockerfile:1

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

What matters here:

  • The build stage installs everything and creates the compiled output.
  • The runtime stage starts fresh and copies only what it needs.
  • The final image does not carry dev dependencies or build tooling unless you explicitly leave them there.

If your app is static, the final stage might be Nginx or Caddy instead of Node. If it is a Go binary, the final stage might be a tiny base image containing just the compiled binary and certificates.

Caution: Keep secrets out of the Dockerfile. Do not bake API keys, database passwords, or local env files into image layers.

Step 4: Build and test the image locally

Choose an explicit version tag before building. Avoid relying only on latest.

export IMAGE_NAME=yourname/myapp
export IMAGE_TAG=1.0.0

docker build -t $IMAGE_NAME:$IMAGE_TAG -t $IMAGE_NAME:latest .

Inspect the built image:

docker images | grep myapp

Run it locally:

docker run --rm -p 3000:3000 --name myapp-test $IMAGE_NAME:$IMAGE_TAG

Then verify the app actually works:

curl -I http://localhost:3000

If the app has a UI or API endpoint, test a real path too. A container starting is not enough. You want evidence that:

  • The runtime command works
  • Required files made it into the final image
  • The exposed port matches the app
  • The app does not depend on a missing build-time file

If you need env vars, pass them explicitly during the local test with a real local test env file instead of assuming your example template contains runnable values:

docker run --rm -p 3000:3000 --env-file .env.local-test $IMAGE_NAME:$IMAGE_TAG

Step 5: Log in, tag clearly, and push to your registry

Log in to the target registry. For Docker Hub:

docker login

For GitHub Container Registry, the tag usually includes the full registry path:

export IMAGE_NAME=ghcr.io/yourorg/myapp
export IMAGE_TAG=1.0.0

docker build -t $IMAGE_NAME:$IMAGE_TAG -t $IMAGE_NAME:latest .

Push both the versioned tag and, if you choose to maintain it, latest:

docker push $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest

The safer deployment habit is to redeploy servers using the explicit version tag and treat latest as optional convenience, not the source of truth.

Verification: Confirm the registry now shows the tag you pushed. Most registries display image size, push time, and digest. Save the digest if you want a stronger audit trail.

Step 6: Pull the image on the server, redeploy, and verify

On the server, update your Compose file or env file to use the explicit image tag:

services:
  app:
    image: yourname/myapp:1.0.0

Then pull and restart:

docker compose pull
docker compose up -d

Verify the running state:

docker compose ps
docker compose images
docker compose logs --tail=100
curl -I http://localhost:3000

If the service sits behind a reverse proxy, also test the public URL or the actual app route that matters.

Recovery-first habits:

  • Keep the previous image tag documented before you change anything.
  • Do not delete the old tag from the registry immediately after a push.
  • If the new image fails, revert the Compose tag and redeploy the known-good version.

Troubleshooting common image build and push problems

The build context is huge.
Check .dockerignore for .git, dependency directories, logs, and build artifacts you do not need.

The final container starts, but the app crashes.
You likely forgot to copy a required runtime file from the build stage or installed only production dependencies when the app still expects a dev-time package at runtime.

The pushed image works locally but not on the server.
Compare environment variables, filesystem paths, and CPU architecture. Also verify the server pulled the new tag instead of continuing to run a cached old image.

I accidentally deployed latest and lost track of what changed.
Switch back to explicit version tags immediately. Use docker compose images and the registry digest view to identify what actually ran.

Push is denied.
Check the registry namespace, your login status, and whether the repository name exactly matches the destination you are allowed to write to.

Warning: Image build success is not deployment success. Always include a local runtime test and a server-side verification step before calling the release done.

What to do next

Once your image workflow is reliable, the next strong improvement is safe updates and rollback habits on the server. Continue with How to Update Docker Compose Apps Safely With Rollbacks.