Skip to content
DevOps AI ToolKit
Newsletter

GitHub AI Engineering Academy · Part 6 of 16

GitHub Copilot with Docker: Build Better Containers with AI

Level: Intermediate Copilot · Containers ~26 min Part 6/16
Academy progress6 / 16
Academy curriculum (16 lessons)

Containers sit at the center of modern DevOps: they are how application code becomes a portable, reproducible artifact that runs the same on a laptop, in CI, and in production. GitHub Copilot lives right where that artifact is defined — the Dockerfile, the Compose file, the CI workflow — turning a comment or a prompt into a working first draft. But everything it generates is a draft to read, build, scan, and review. A Dockerfile that builds is not automatically secure, minimal, or production-ready.

This is Part 6 of the GitHub AI Engineering Academy. Part 4, GitHub Copilot with VS Code, showed Copilot across the whole DevOps surface inside the IDE; Part 5, GitHub Copilot with Terraform, went deep on AI-assisted infrastructure as code. This lesson focuses entirely on Docker, and keeps one frame throughout: Copilot proposes → you read and understand → docker build and a scanner validate → a human approves → the image ships.

The path a container travels is worth picturing before we start:

App Code
   |
Dockerfile
   |
 Image
   |
Registry
   |
Kubernetes / Cloud / Server

Copilot assists at the first two steps — writing the Dockerfile and the Compose and CI files that produce the image. It does not decide whether the image is safe. That decision comes from a pipeline of deterministic checks with review at the center:

Copilot Generates
      |
  docker build
      |
 container test
      |
 security scan
      |
engineer review   <-- required
      |
   registry
      |
 deployment

The two review-shaped steps — the scan and the engineer reading the result — are what make Copilot’s speed safe to use on infrastructure.

What You’ll Learn

  • Docker fundamentals, briefly — images, containers, Dockerfiles, registries, Compose, volumes, and networks, and where the site’s deeper Docker curriculum lives.
  • Why pair Copilot with Docker — generating and improving Dockerfiles, explaining directives, drafting Compose, debugging builds and runtime, and optimizing layers.
  • Writing a first Dockerfile with Copilot — the naive draft, then a hardened version with non-root, caching, pinning, a health check, and a .dockerignore.
  • Multi-stage builds and layer caching — smaller, safer images and faster rebuilds, with the reasoning behind each change.
  • Compose, networking, and volumes — a realistic FastAPI + PostgreSQL + Redis stack, service-name DNS, and data persistence.
  • Troubleshooting — build failures and the “why did my container exit?” runtime loop, using docker ps -a, logs, inspect, and network inspect.
  • Security and image scanning — a security-review prompt plus authoritative scanning with Trivy and Docker Scout.
  • CI/CD with GitHub Actions — building, scanning, and pushing images to GHCR with verified actions and least-privilege permissions, plus multi-arch builds.
  • 25 reusable prompts and a hands-on lab that builds, secures, and tests a Dockerized AI API end to end.

What Is Docker?

Docker packages an application and its dependencies into an image — a read-only, layered filesystem plus metadata about how to run it. A running instance of an image is a container: an isolated process with its own filesystem, network namespace, and resource view. You describe how to build an image in a Dockerfile, a sequence of instructions (FROM, COPY, RUN, CMD, and so on) that Docker executes layer by layer.

A few more pieces complete the picture:

  • Registries store and distribute images. Docker Hub is the public default; GHCR (ghcr.io) is GitHub’s native registry, which matters later when Actions builds and pushes.
  • Docker Compose defines multi-container applications — an app plus its database and cache — in a single compose.yaml, brought up with docker compose up.
  • Volumes persist data outside a container’s ephemeral filesystem, so a database survives a container restart.
  • Networks connect containers so they can reach each other, with built-in service discovery by name.

This lesson assumes you know these concepts and focuses on using Copilot with them. If you want to build the fundamentals or go deeper, the hands-on Docker Academy and the Docker guides cover images, layers, Compose, and runtime in depth. Treat this lesson as a complement to that curriculum, not a replacement for it.

Why Use GitHub Copilot with Docker?

Dockerfiles and Compose files are mechanical, verbose, and full of easy-to-forget best practices — exactly the kind of work an AI pair programmer accelerates. Copilot is powered by a rotating set of models and works as inline completions plus Copilot Chat (Ask, Edit, and Agent modes) in VS Code, and through the agentic copilot CLI. When it runs a command — in the CLI or in VS Code Agent mode — it is approval-gated: you review and approve each command before it executes.

Concretely, Copilot helps you:

  • Generate and improve Dockerfiles — draft a working file, then harden it for production.
  • Explain directives — what EXPOSE actually does, why COPY order affects caching, what a HEALTHCHECK reports.
  • Draft Compose stacks — multi-service environments with volumes, networks, and health checks.
  • Debug builds and runtime — interpret a failed RUN, a crash loop, or a database connection error.
  • Optimize layers — spot cache-busting ordering and unnecessary work.
  • Add production hygiene — non-root users, health checks, pinned tags, and a tight build context.
  • Wire up CI/CD — GitHub Actions workflows that build, scan, and push images.

The value is real, but the framing never changes. Copilot writes faster than you; it does not know your registry, your base-image policy, or your threat model. Every generation is a proposal that docker build, a scanner, and your review turn into a decision.

Creating Your First Dockerfile with Copilot

Work through a concrete project. Create a small demo repository so the examples are grounded:

copilot-docker-demo/
  app/
    main.py            FastAPI app, /health route
  tests/
    test_main.py       endpoint tests
  Dockerfile
  compose.yaml
  requirements.txt
  .dockerignore
  README.md

The app is a minimal FastAPI service exposing a /health route. With requirements.txt listing fastapi and uvicorn, open a new Dockerfile and describe the goal in a comment, or ask in Copilot Chat:

“Write a Dockerfile for this Python FastAPI app, Python 3.12, expose 8000.”

A first draft typically looks like this:

FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

This builds and runs. It is also a good example of why “it builds” is not the finish line. Read it critically:

  • The base image is the full python:3.12, not slim — hundreds of megabytes of tooling you do not need at runtime, and a larger attack surface.
  • COPY . . before pip install means any code change busts the dependency cache, so every rebuild reinstalls everything.
  • COPY . . copies the entire build context.git, caches, a stray .env — into the image, because there is no .dockerignore.
  • The container runs as root. A process escape starts with root inside the container.
  • Nothing is pinned beyond the Python minor version, and there is no health check, so an orchestrator cannot tell whether the app is actually serving.

None of these stop the build. All of them matter in production. That gap — between a Dockerfile that builds and one that is ready — is the entire reason to review what Copilot writes.

Improving the Dockerfile

Ask Copilot to fix the specific problems rather than a vague “make it better”: “Harden this for production — slim base, install dependencies before copying source for caching, run as a non-root user, add a health check, set an explicit WORKDIR, and use --no-cache-dir.” A stronger single-stage result:

# syntax=docker/dockerfile:1
FROM python:3.12-slim

# Don't buffer stdout/stderr; no .pyc files in the image
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

WORKDIR /app

# Copy only the manifest first so this layer caches
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Now copy source; code changes don't bust the deps layer
COPY app/ ./app/

# Create and switch to an unprivileged user
RUN useradd --create-home --uid 10001 appuser
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD python -c "import urllib.request; \
  urllib.request.urlopen('http://localhost:8000/health')" \
  || exit 1

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", \
     "--port", "8000"]

Have Copilot explain each change, and confirm it against the diff:

  • python:3.12-slim trims the base to a much smaller runtime with fewer packages to patch. (Whether slim is the right choice depends on your dependencies — more on base images later.)
  • ENV PYTHONUNBUFFERED=1 / PYTHONDONTWRITEBYTECODE=1 make logs appear immediately and keep .pyc clutter out of the image.
  • Cache-friendly orderingCOPY requirements.txt then pip install then COPY app/. The dependency layer only rebuilds when requirements.txt changes, so ordinary code edits rebuild in seconds.
  • --no-cache-dir stops pip from leaving its download cache inside the image.
  • Non-root appuser via useradd plus USER — the single highest-value hardening step.
  • Explicit WORKDIR /app and a narrow COPY app/ ./app/ keep the layout predictable and the context small.
  • HEALTHCHECK hits the real /health route so Docker and orchestrators know the app is serving, not merely running.
  • A JSON-array CMD runs the process directly (no shell wrapper), so signals reach the app for clean shutdown.

✅ Best Practice — Ask Copilot for the naive version to see the shape, then explicitly ask it to harden the specific weaknesses — slim base, cache ordering, non-root, pinning, health check. Finish by building the image and reading the result. Generate, understand, build, scan — never generate and trust.

Reproducibility is worth a note. Pinning the base to python:3.12-slim pins a minor version, not an exact image; for stricter reproducibility, pin to a digest (python:3.12-slim@sha256:...) and pin your Python dependencies to exact versions in requirements.txt. Copilot can generate the digest-pinned form, but you confirm the digest against the registry.

Multi-Stage Builds

The single-stage file above is already reasonable, but many real apps need build-time tooling — compilers, headers, build backends — that the running app never uses. A multi-stage build puts that tooling in a throwaway build stage and copies only the finished artifacts into a slim runtime stage. Ask Copilot to “refactor this into a multi-stage build: a build stage that installs dependencies into a virtualenv, and a slim runtime stage that copies only the venv and the app.”

# syntax=docker/dockerfile:1

# ---- build stage ----
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /opt/venv \
    && /opt/venv/bin/pip install --no-cache-dir \
       -r requirements.txt

# ---- runtime stage ----
FROM python:3.12-slim AS runtime
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1
WORKDIR /app

RUN useradd --create-home --uid 10001 appuser
COPY --from=build /opt/venv /opt/venv
COPY app/ ./app/
USER appuser

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD python -c "import urllib.request; \
  urllib.request.urlopen('http://localhost:8000/health')" \
  || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", \
     "--port", "8000"]

What the two stages buy you:

  • A smaller attack surface — build tools (compilers, -dev packages, pip’s caches) never reach the runtime image, so there is less to exploit and less to patch.
  • Artifact copyingCOPY --from=build /opt/venv /opt/venv pulls only the installed virtualenv forward. Nothing from the build stage’s intermediate layers ships.
  • Cleaner separation — the build stage can be as messy as it needs to be; the runtime stage stays minimal.

❗ Important — Multi-stage builds usually reduce attack surface, but do not assume they always shrink the image. If your runtime stage still installs heavy libraries, or your dependencies pull in large native wheels, the final image can be just as big. Build both versions and compare docker images output — the numbers, not the pattern, are the evidence.

Layer Caching

Docker builds images as a stack of cached layers. A layer is reused only if the instruction and its inputs are unchanged; once one layer changes, every layer after it rebuilds. The order of instructions therefore decides how fast your rebuilds are.

The naive draft had the poor pattern:

COPY . .
RUN pip install -r requirements.txt

Because COPY . . runs first, any change to any file — a one-line edit to main.py — invalidates that layer, which invalidates the pip install below it. Every rebuild reinstalls all dependencies, even though they did not change.

The cache-friendly pattern copies the manifest first:

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/

Now the pip install layer depends only on requirements.txt. Edit application code and Docker reuses the cached dependency layer, rebuilding only the fast final COPY. Dependencies reinstall only when you actually change them.

Copilot is good at spotting this. Paste a slow Dockerfile and ask “why is this rebuilding dependencies on every code change, and how do I fix the layer ordering?” It will usually identify the COPY . . placement and propose the manifest-first pattern. Confirm the fix by editing a source file and rebuilding — the dependency layer should report CACHED.

🛠️ DevOps Tip — Order Dockerfile instructions from least- to most-frequently-changing: base image, system packages, dependency manifests, then application source last. That ordering maximizes cache hits, and it is the first thing to check when local builds feel slow.

.dockerignore

The build context is everything Docker sends to the daemon when you run docker build — by default, the entire directory. COPY . . then copies whatever it is told from that context into the image. Both are problems if the context contains files you do not want shipped or transmitted. A .dockerignore file, sitting next to the Dockerfile, excludes paths from the context entirely:

.git
.github
__pycache__
*.pyc
.env
.venv
terraform.tfstate*

Why each line matters:

  • .git and .github — version history and CI config bloat the context and have no place in a runtime image.
  • __pycache__ and *.pyc — build artifacts that should be regenerated, not copied.
  • .env — the single most important line. A local .env often holds real credentials; excluding it keeps secrets out of the image and out of the context sent to the daemon.
  • .venv — a host virtualenv that is wrong for the container’s architecture and just wastes space.
  • terraform.tfstate* — state files can contain sensitive values; they must never end up in an image.

Ask Copilot to “generate a .dockerignore for a Python project that excludes caches, virtualenvs, VCS, and secrets,” then read it and add anything project-specific. A tight .dockerignore also speeds builds, because a smaller context transfers faster.

⚠️ Warning — Without a .dockerignore, a COPY . . can silently bake a .env, cloud credentials, or a .tfstate into an image that then gets pushed to a registry. Anyone who can pull the image can extract those files from its layers. Add the ignore file before your first build, and never rely on COPY . . to be selective on its own.

Running as Non-Root and Health Checks

Non-root runtime. By default a container runs as root, and root inside the container is closely related to root on the host. If an attacker exploits your app, starting as root makes escape and damage easier. Running as an unprivileged user is defense in depth that costs almost nothing. The pattern has a few moving parts:

# Create a dedicated user with a fixed, high UID
RUN useradd --create-home --uid 10001 appuser

# Ensure the app's files are owned by that user
COPY --chown=appuser:appuser app/ ./app/

# Give the app a writable directory if it needs one
RUN mkdir -p /app/tmp && chown appuser:appuser /app/tmp

# Drop to the unprivileged user for the rest of the build
# and for runtime
USER appuser

Things to get right when Copilot generates this:

  • Filesystem ownership — files the app must read (or write) need to be owned by, or readable by, the runtime user. COPY --chown and targeted chown handle this.
  • Privileged ports — a non-root process cannot bind ports below 1024. Bind the app to 8000 (as here) and publish it to 80/443 at the orchestrator or proxy, rather than running as root just to bind 80.
  • Writable directories — if the app writes temp files or caches, create those directories and grant ownership; otherwise a read-only or root-owned path causes permission errors at runtime.
  • Order of operations — switch to USER appuser after the steps that need root (installing packages, creating directories), not before.

Health checks. A running container is not necessarily a healthy one — the process can be up while the app is deadlocked, still starting, or unable to reach its database. A HEALTHCHECK gives Docker a real signal:

Application
     |
Health Endpoint  (/health)
     |
Docker Health Check
     |
  healthy / unhealthy

You can define it in the Dockerfile (as shown earlier) or in Compose:

services:
  api:
    build: .
    healthcheck:
      test: ["CMD", "python", "-c",
        "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 5s

The health check turns “the container is running” into “the app is actually serving requests” — a distinction orchestrators depend on to route traffic and restart failed instances. Running is not the same as healthy; make the app say so.

✅ Best Practice — Point the health check at a route that exercises the app’s real readiness (it can serve a request), and keep start_period long enough to cover a slow boot so a healthy app is not marked unhealthy while it starts. Ask Copilot to explain what a given health check actually tests before you trust it.

Environment Variables and Secrets

Containers are configured through environment variables, and there are several places they can come from — with very different safety properties:

  • ENV in the Dockerfile — bakes a value into the image, visible in every layer to anyone who pulls it. Fine for non-secret defaults (PYTHONUNBUFFERED=1), never for secrets.
  • Runtime env vars — passed at docker run time (-e VAR=value) or by the orchestrator; not stored in the image.
  • Compose environment: — set per service in compose.yaml; good for non-secret config, and for referencing values that come from elsewhere.
  • A .env file — untracked local file that Compose reads for variable substitution; kept out of version control and out of the build context.
  • A secrets manager — the real answer for production credentials (cloud secret stores, Docker/Swarm secrets, Kubernetes Secrets).

The rule is simple: configuration can live in the image; secrets cannot.

⚠️ Warning — Never write a real secret into a Dockerfile. A line like ENV OPENAI_API_KEY=sk-real-secret-value embeds the key in an image layer permanently — pushing the image leaks it, and deleting the line in a later layer does not remove it from history. Use placeholders in files you commit, inject secrets at runtime, and if Copilot inlines a literal credential, replace it with a reference before you build. In every example in this lesson, treat values like ${DB_PASSWORD} as placeholders sourced from an untracked .env or a secrets manager.

GitHub Copilot with Docker Compose

For local development you rarely run one container. Describe the stack to Copilot — “a compose.yaml for a FastAPI app with PostgreSQL and Redis: health checks, named volumes, a private network, and config from environment variables” — and refine the draft:

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/appdb
      REDIS_URL: redis://cache:6379/0
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    restart: unless-stopped
    networks: [backend]

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks: [backend]

  cache:
    image: redis:7
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks: [backend]

volumes:
  postgres-data:
  redis-data:

networks:
  backend:

Two things to verify in any Copilot-generated Compose file:

  • No top-level version: key. The version: field is obsolete — modern docker compose ignores it and may warn. If Copilot adds version: "3.8" at the top (older examples are all over its training data), delete it.
  • Validate the file. Run docker compose config to have Compose parse, interpolate variables, and print the effective configuration. It catches indentation errors, unknown keys, and unresolved variables before you try to bring anything up.
docker compose config

Health-gated dependencies. The most important detail is depends_on with condition: service_healthy:

    depends_on:
      db:
        condition: service_healthy

Plain depends_on only waits for a container to start, not to be ready. PostgreSQL’s container is up long before Postgres accepts connections. With condition: service_healthy, Compose waits for the database’s health check to pass before starting the API — so the app does not race a database that is not listening yet. Container start is not readiness.

❗ Important — Health-gated depends_on reduces startup races but does not eliminate them. A database can pass its health check and then drop a connection, or restart mid-run. Applications still need retry and reconnection logic; app-level resilience is not optional just because Compose waited. Ask Copilot to add a connection-retry loop to your startup code, and test it by restarting the database while the app runs.

Docker Networking with Copilot

When Compose brings up a stack, it creates a network and attaches every service to it. Services discover each other by service name through Docker’s built-in DNS. In the stack above, the API reaches the database at db:5432 and Redis at cache:6379 — the service names, not IP addresses:

Browser :8000
     |
   API  (service: api)
     |
postgres:5432  (service: db)
     |
 PostgreSQL

The point that trips people up: inside a container, localhost means that container, not the host or another service. An app configured with localhost:5432 will fail to reach a database running in a different container, even on the same Compose network. It must use the service name — db:5432. Copilot knows this pattern and will use service names in generated Compose configs; when a connection fails, “is the app using the service name or localhost?” is the first question to ask it.

Ports come in two flavors:

  • Published ports (ports: - "8000:8000") map a container port to the host, making the service reachable from outside — for the API you want to hit from your browser.
  • Internal ports need no ports: entry. The database and cache talk to the API over the backend network without being published to the host, which keeps them off your machine’s exposed surface.

Notice the example publishes only the API. The database and Redis are reachable by the API over the private network but are not exposed to the host — the right default. You can define custom networks to segment traffic further (for example, a separate frontend network for anything the API must expose).

To confirm what is actually connected, inspect the network:

docker network inspect copilot-docker-demo_backend

That prints the attached containers, their aliases, and their addresses. Ask Copilot to interpret the output if a service is unexpectedly missing from the network.

🔍 Troubleshooting — When one container cannot reach another, exec into the caller and test by service name: docker exec -it <api-container> sh, then try connecting to db:5432. If the name does not resolve, the services are on different networks or the target is not up; if it resolves but refuses, the target is not listening yet. Give Copilot the specific error and the docker network inspect output rather than a vague “it can’t connect.”

Docker Volumes

Container filesystems are ephemeral — remove the container and its writes are gone. Databases need their data to outlive any single container, which is what volumes provide. There are three storage modes to keep straight:

  • Named volumes — Docker-managed storage referenced by name (postgres-data), ideal for databases. Declared under the top-level volumes: key and mounted into a service.
  • Bind mounts — a host directory mapped into the container, useful in development to edit code live, but tied to the host’s paths and permissions.
  • Ephemeral storage — the container’s writable layer, discarded when the container is removed. Fine for scratch data, wrong for anything you need to keep.

The Compose stack uses a named volume for Postgres:

    volumes:
      - postgres-data:/var/lib/postgresql/data

and declares it at the top level:

volumes:
  postgres-data:

That mounts the managed postgres-data volume at PostgreSQL’s data directory, so the database survives docker compose down and container recreation. The data persists until you explicitly delete the volume.

⚠️ Warningdocker compose down -v and docker volume rm delete volume data permanently — there is no undo. Copilot will happily suggest -v to “clean up,” which is fine for a throwaway dev stack and catastrophic for one holding real data. Read cleanup commands before running them, and never let an Agent-mode command remove volumes without your explicit approval.

Troubleshooting Docker Builds and Runtime

Copilot is a strong troubleshooting partner because it can pattern-match errors quickly — but the container is the source of truth. Gather evidence first, then let Copilot explain it.

Build failures. Common build errors and their usual causes:

  • Package not found — a mistyped or nonexistent dependency, or a missing system package before pip install.
  • Missing file / COPY failed — a COPY source path that does not exist or is excluded by .dockerignore.
  • Architecture mismatch — a base image or wheel built for a different platform than you are building on.
  • Build-context problems — a file the Dockerfile expects is outside the context, or the context is huge because .dockerignore is missing.
  • Network errors — the build cannot reach a package index.
  • Permission denied — writing to a path the current build user does not own.

A disciplined build-debugging loop:

Build Error
     |
Copilot Explanation  (hypothesis)
     |
inspect Dockerfile
     |
inspect build context
     |
    fix
     |
  rebuild

Paste the failing build output and ask “why did this build fail and which instruction is responsible?” Then verify against the Dockerfile and the context — for a COPY failure, check the source path exists and is not in .dockerignore — before applying the fix.

Runtime failures. Once an image builds, a different class of problems appears: the container exits immediately, cannot reach the database, finds its port unavailable, is missing an env var, hits a permission error, fails its health check, has a broken volume mount, or cannot resolve a service name. The tools to diagnose these are stable and worth memorizing:

CommandWhat it tells you
docker ps -aAll containers and their state, including exited ones and exit codes
docker logs <c>The container’s stdout/stderr — the actual error
docker inspect <c>Full config: command, mounts, env, network, exit code
docker statsLive CPU/memory use — spotting resource exhaustion
docker exec -it <c> shA shell inside a running container to test from within
docker network inspect <net>Which containers are attached and their addresses

Why Did My Container Exit?

The most common runtime question deserves a focused walkthrough. A container that starts and immediately stops is following a specific chain of evidence:

  1. docker ps -a — find the container and read its status. An exit code of 0 means it ran to completion (often the command was a one-shot, not a server); non-zero means it failed. Exit 137 typically means it was killed (frequently out-of-memory); a Python traceback usually surfaces as a non-zero application exit.
  2. docker logs <container> — read the last output before it died. This is where a stack trace, a “connection refused,” or a “missing environment variable” appears.
  3. docker inspect <container> — when logs are not enough, check the details:
    • the exit code and the exact command that ran,
    • the mounts (is the expected volume attached?),
    • the networking (is it on the right network?),
    • the env (is a required variable actually set?).

Feed those specifics to Copilot: “This container exits with code 1; here are the logs and the command it ran — what is the likely cause?” It will usually propose a hypothesis — a missing env var, an app that finished instead of serving, a database it could not reach. Verify the hypothesis against the evidence, fix the one thing that is wrong, and rebuild or restart. The pattern is always the same: ps -a to find it, logs to hear it, inspect to see its configuration.

🔍 Troubleshooting — A container that “won’t stay up” but exits 0 is usually running the wrong kind of command — a script that completes rather than a server that blocks. Check that CMD starts a long-running process (like uvicorn) and that the app binds to 0.0.0.0, not 127.0.0.1, or it will be unreachable from outside the container even while it runs.

Image Optimization and Base Image Selection

Ask Copilot to “review this Dockerfile for image size and efficiency,” and it will look for the usual sources of bloat:

  • an oversized base image (python:3.12 where slim would do),
  • unnecessary packages installed and never removed,
  • excessive layers from many separate RUN commands,
  • build artifacts and package-manager caches left in the image,
  • a large build context from a missing .dockerignore,
  • poor dependency ordering that defeats layer caching.

Its suggestions are good leads. What Copilot cannot do is measure the result — so treat optimization as a before-and-after experiment, not a set of claims:

docker build -t demo:before .
# apply the suggested changes
docker build -t demo:after .
docker images | grep demo

Compare the actual sizes, and confirm the app still works after each change. Do not repeat a size or speed number that no build produced.

Base image selection is the highest-leverage choice, and it is a set of tradeoffs rather than a single best answer:

Base typeSizeCompatibilityDebuggabilitySecurity surface
Full distro (python:3.12)LargeHighestEasiest (has a shell, tools)Largest
Slim (python:3.12-slim)SmallGood for mostReasonableSmaller
Minimal / distrolessSmallestCan miss libsHard (no shell)Smallest

Smaller is not automatically better. A slim image can be missing a system library your dependency needs, turning a build error into a debugging session. A distroless or minimal image has the smallest surface but no shell, which makes docker exec debugging impossible and can complicate health checks. slim is a sensible default for many Python services; the right choice depends on your dependencies and how much in-container debugging you need. Ask Copilot to explain what a given base image includes and omits, then decide with your own constraints in mind.

Docker Security Review

Beyond size, review the image as a security artifact. A useful prompt is to give Copilot a role:

“Review this Dockerfile as a container security engineer and list the risks.”

Copilot will typically check for:

  • Root runtime — no USER directive, so the container runs as root.
  • Untrusted base images — an unofficial or unknown base image.
  • Floating tagslatest or an unpinned tag that can change under you.
  • Secrets in the image — credentials in ENV, ARG, or copied files.
  • Unnecessary packages — build tools or utilities that expand the attack surface.
  • Dangerous permissions — world-writable paths, or chmod 777.
  • Exposed management ports — databases or admin interfaces published needlessly.
  • Writable-filesystem assumptions — an app that needs a writable root when it could run read-only.
  • Package-manager cache — apt/pip caches left behind, bloating layers and adding surface.
  • Supply-chain risks — unpinned dependencies pulled from the network at build time.

This review is valuable, and it is not authoritative. Copilot reasons about the text of your Dockerfile; it does not enumerate the actual vulnerabilities in your base image and installed packages. That is what an image scanner does, and the scanner is the source of truth:

# Trivy: scan a built image for CVEs
trivy image demo:after

# Docker Scout: CVE summary for an image
docker scout cves demo:after

Trivy (trivy image <img>) and Docker Scout (docker scout cves <img>) are current, widely used scanners that report real CVEs in your image’s OS packages and application dependencies, with severities and fixed versions. The full loop:

Build
  |
Scan   (Trivy / Docker Scout)
  |
Review
  |
Remediate  (bump base, pin, drop pkgs)
  |
Rebuild

Use Copilot to explain a scanner finding — “what is this CVE, and does upgrading the base image fix it?” — and to draft the remediation. Let the scanner decide what is actually vulnerable. Copilot explains CVEs; the scanner is authoritative. The security hardening guides go deeper on container hardening and scanning.

⚠️ Warning — A clean Copilot review does not mean a clean image. Copilot cannot see the CVEs inside your base image or your dependency tree; only a scanner can. Always scan the built image before pushing, and fail your pipeline on serious, fixable findings rather than treating the scan as advisory.

Tags, Versioning, and Registries

How you tag images decides whether a deployment is reproducible. The latest tag is a trap in production: it is mutable, so latest today and latest next week can be different images, and a rollback becomes guesswork. Prefer immutable, versioned tags:

  • Semantic versions1.4.2, so you know exactly what is deployed.
  • Git SHAsgit-sha-a1b2c3d, tying an image to the exact commit that built it.
  • Date or release tagsrelease-2026-08, for time-based release trains.

Push the same image under a specific tag and optionally a moving one (1.4.2 plus latest), but deploy the specific tag so the running version is unambiguous.

GHCR — the GitHub Container Registry at ghcr.io — is the GitHub-native place to store images, and it integrates cleanly with Actions and repository permissions:

GitHub Repo
     |
  Actions
     |
Docker Build
     |
   GHCR  (ghcr.io/<owner>/<image>)

That is the shape of the CI pipeline in the next section. Registries, tag immutability, and retention policies get deeper treatment in later lessons; here the rule is enough: version your tags, never deploy latest.

GitHub Copilot + Docker + GitHub Actions

Building an image by hand is fine for development; production images should come from CI, where every image is built, tested, and scanned the same way. Ask Copilot to “write a GitHub Actions workflow that builds a Docker image with Buildx, scans it, and pushes to GHCR on the main branch, with least-privilege permissions.” The verified building blocks:

name: docker

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Derive image tags
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}

      - name: Log in to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build image (load locally to scan)
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: ${{ steps.meta.outputs.tags }}

      - name: Scan image with Trivy
        run: |
          # Fail the build on serious, fixable vulnerabilities
          trivy image --exit-code 1 --severity HIGH,CRITICAL \
            --ignore-unfixed \
            $(echo "${{ steps.meta.outputs.tags }}" | head -n1)

      - name: Push image (trusted refs only)
        if: github.event_name != 'pull_request'
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

What to verify in any Copilot-generated Docker workflow:

  • Pinned, real actionsactions/checkout@v4, docker/setup-buildx-action@v3, docker/metadata-action@v5, docker/login-action@v3, and docker/build-push-action@v6 are current major versions. Confirm each against its repository and reject any invented or unversioned uses:.
  • Least-privilege permissions:contents: read plus packages: write is exactly what pushing to GHCR needs, and no more. Copilot often omits the block; add it.
  • GITHUB_TOKEN for GHCR — the login uses the built-in token, so you do not manage a separate registry credential.
  • Do not push from untrusted PRs — the login and push steps are gated with if: github.event_name != 'pull_request'. A pull request from a fork must never be able to publish an image or use write credentials. Build and scan on PRs; push only on trusted refs like main.
  • A scan that actually failstrivy image --exit-code 1 --severity HIGH,CRITICAL breaks the build on serious findings. A scan step that always passes is theater.

❗ Important — Untrusted pull request contexts are a real attack surface. If a workflow both runs on pull_request and has packages: write or push credentials available to fork PRs, a malicious PR can exfiltrate the token or push a poisoned image. Keep write access and push steps on trusted events only, and review any Copilot workflow for this before merging.

Buildx and multi-arch. The same docker/build-push-action (backed by Buildx) can produce images for multiple CPU architectures — important when you build on amd64 runners but deploy to arm64 hosts:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t ghcr.io/owner/demo:1.4.2 --push .
Source
   |
 Buildx
   |
 +--------+--------+
 |                 |
amd64            arm64
 |                 |
     Registry  (multi-arch manifest)

Cross-platform builds may need QEMU emulation (via docker/setup-qemu-action) or native builders for the target architecture; emulated builds are slower and occasionally behave differently from native ones. Ask Copilot to add multi-arch support, then confirm the resulting manifest lists both platforms.

Part 5, GitHub Copilot with Terraform, covers the CI patterns for infrastructure code; the CI/CD guides go broader on pipeline design.

Copilot for Docker Documentation

Container setups accumulate implicit knowledge — which env vars are required, which port to hit, how to run the stack locally. Copilot drafts that documentation quickly. Ask it to “write a README section documenting how to build and run this image, its environment variables, exposed ports, and common troubleshooting steps,” and it will produce a solid first pass covering:

  • Build and run commandsdocker build and docker run (or docker compose up) with the right flags.
  • Environment variables — each variable, whether it is required, and its default.
  • Ports — what is exposed and how to reach the app.
  • Troubleshooting — the container-exit and connectivity checks from this lesson.

The one hazard is drift: generated docs describe what the code looks like it does, and they are not re-checked when the Dockerfile or Compose file changes.

⚠️ Warning — Verify generated documentation against the actual Dockerfile and compose.yaml. Copilot will confidently document a port or an env var that you renamed, or a run command that no longer matches the entrypoint. Treat the draft as a starting point you check line by line against the real files, and update it whenever the container setup moves.

25 GitHub Copilot Prompts for Docker Engineers

Reusable starting prompts. Each produces a draft to read, build, scan, and review before you ship.

Dockerfiles

  1. “Write a Dockerfile for this Python FastAPI app, Python 3.12, exposing port 8000.”
  2. “Harden this Dockerfile for production: slim base, non-root user, health check, and pinned versions.”
  3. “Refactor this Dockerfile into a multi-stage build with a slim runtime stage.”
  4. “Reorder these instructions so dependency layers cache and code changes rebuild fast.”
  5. “Add a non-root user to this Dockerfile and fix the file ownership so the app still runs.”
  6. “Add a HEALTHCHECK that hits the /health endpoint and explain what it tests.”
  7. “Explain what each instruction in this Dockerfile does and why the order matters.”

Build context and optimization

  1. “Generate a .dockerignore for a Python project that excludes caches, virtualenvs, VCS, and secrets.”
  2. “Review this Dockerfile for image size and tell me what is inflating it.”
  3. “Compare a slim base image with a full one for this app and list the tradeoffs.”
  4. “Why is this build reinstalling dependencies on every code change, and how do I fix it?”

Compose, networking, and volumes

  1. “Write a compose.yaml for a FastAPI app with PostgreSQL and Redis, with health checks and a private network.”
  2. “Add health-gated depends_on so the API waits for the database to be ready.”
  3. “Remove the obsolete top-level version key from this Compose file and validate it.”
  4. “Explain how the services in this Compose file reach each other over the network.”
  5. “Add a named volume so PostgreSQL data persists across restarts.”

Troubleshooting

  1. “This container exits immediately with code 1; here are the logs — what is the likely cause?”
  2. “Explain why this build failed at the COPY step and which path is wrong.”
  3. “The API can’t reach the database; walk me through debugging the Compose networking.”
  4. “Interpret this docker inspect output and tell me what env var is missing.”

Security and CI/CD

  1. “Review this Dockerfile as a container security engineer and list the risks.”
  2. “Explain this Trivy finding and whether upgrading the base image fixes it.”
  3. “Write a GitHub Actions workflow that builds, scans with Trivy, and pushes to GHCR on main only.”
  4. “Add least-privilege permissions and gate the push so fork PRs can’t publish images.”
  5. “Add multi-arch support for linux/amd64 and linux/arm64 to this build.”

Lab: Build, Secure, and Test a Dockerized AI API with GitHub Copilot

Put the whole lesson together by containerizing a small AI-style API with Copilot as your assistant — the point is the cycle, not the specific files. Work each step through the same loop: Copilot proposes → you read → docker build / a scanner validates → you approve.

  1. App — ask Copilot for a minimal FastAPI app in app/main.py with a /health route returning {"status": "ok"} and one endpoint that reads a value from an environment variable. Run it locally and confirm the route responds.
  2. Dockerfile — ask Copilot for a Dockerfile (Python 3.12, expose 8000). Read the naive draft and note its weaknesses.
  3. Builddocker build -t ai-api:dev . and confirm it builds.
  4. Rundocker run -p 8000:8000 ai-api:dev and curl /health to confirm the container serves.
  5. Inspect — use docker ps -a, docker logs, and docker inspect to see the container’s state, command, and config.
  6. .dockerignore — have Copilot generate one (excluding .git, caches, .env, .venv, state), rebuild, and confirm the context shrank.
  7. Non-root — ask Copilot to add a non-root user; fix file ownership; rebuild and confirm the process runs as appuser (docker exec + id).
  8. Layer caching — reorder to copy requirements.txt and install before copying source; edit a source file, rebuild, and confirm the dependency layer reports CACHED.
  9. Health check — add a HEALTHCHECK hitting /health; run the container and confirm docker ps shows it as healthy.
  10. Compose — ask Copilot for a compose.yaml (no top-level version: key); validate with docker compose config.
  11. PostgreSQL — add a db service with a health check and a named volume; wire the API’s DATABASE_URL to db:5432.
  12. Redis — add a cache service with a health check; wire REDIS_URL to cache:6379; add health-gated depends_on.
  13. Troubleshoot connectivity — deliberately point the API at localhost:5432, watch it fail, and use Copilot plus docker network inspect to diagnose and fix it to db:5432.
  14. Scan — build the final image and run trivy image ai-api:dev (or docker scout cves ai-api:dev); ask Copilot to explain the top findings and remediate the base image.
  15. CI workflow — have Copilot draft .github/workflows/docker.yml that builds, scans, and pushes to GHCR on main only, with permissions: { contents: read, packages: write }; confirm every action version and the PR gating.
  16. Document — ask Copilot for a README covering build, run, env vars, ports, and troubleshooting; verify every command against the actual files.

The end-to-end shape you have practiced:

Application
    |
Copilot Dockerfile
    |
  build
    |
  test
    |
  scan
    |
 Actions
    |
 registry

Commit each piece only after you have read and validated it. By the end you will have used Copilot to generate, harden, debug, secure, and ship a container — while keeping docker build and the scanner as the source of truth.

🛠️ DevOps Tip — Add a repo-level custom instructions file so Copilot defaults to your container conventions — non-root users, pinned base tags, .dockerignore present, least-privilege Actions permissions — across the whole project. Verify the current custom-instructions setup in the VS Code docs, since the mechanism evolves.

What’s Next

You now have Copilot working across the full container lifecycle: writing and hardening Dockerfiles, multi-stage builds and layer caching, Compose stacks with networking and volumes, troubleshooting builds and runtime, security review with Trivy and Docker Scout, and CI/CD to GHCR with GitHub Actions — all under the same discipline that a Dockerfile which builds is not automatically secure, minimal, or production-ready.

The next lesson, Part 7: GitHub Copilot with Kubernetes (coming soon), takes these images to the cluster — deployments, probes, resources, and the security context that production orchestration demands. It builds directly on the images you learned to produce here.

It is worth holding both halves of the picture together: Docker packages applications; Terraform (Part 5) can provision the cloud, network, compute, and Kubernetes environments where those containers run. If you want the infrastructure side, work through Part 5, GitHub Copilot with Terraform. To revisit Copilot across the whole IDE, return to Part 4, GitHub Copilot with VS Code, or to Part 2, GitHub Copilot for DevOps Engineers. The GitHub AI Engineering Academy home has the full path, and the hands-on Docker Academy, the Docker guides, the Kubernetes and Helm guides, the security hardening guides, the Bash and Python automation guides, and the Ubuntu AI Infrastructure series all go deeper on the tools here.

Recommended GitHub Books

Affiliate Disclosure: Some links on this page are affiliate links. If you purchase through one of these links, DevOps AI Toolkit may earn a commission at no additional cost to you. See our affiliate disclosure.

Frequently asked questions

Can GitHub Copilot write Dockerfiles?

Yes. Copilot drafts Dockerfiles quickly from a comment or a chat prompt describing your app — language, version, port, and entrypoint. What it produces is a starting point, not a finished artifact: early drafts often run as root, skip a health check, pin nothing, and copy the whole build context. Treat the draft as a first pass to read, harden, build, and scan. A Dockerfile that builds is not automatically secure, minimal, or production-ready — deterministic tools (docker build, a scanner) are the source of truth; Copilot accelerates the writing.

Can Copilot generate Docker Compose files?

Yes. Describe the stack — for example 'FastAPI app with PostgreSQL and Redis, health checks, named volumes, and a private network' — and Copilot drafts a compose.yaml with services, volumes, networks, and dependencies. Two things to verify: modern Compose has no top-level version key (it is obsolete), and depends_on should use condition: service_healthy so services wait for readiness, not just container start. Validate the result with docker compose config before you rely on it.

Is an AI-generated Dockerfile production-ready?

Not on its own. AI-generated Dockerfiles frequently ship insecure or wasteful defaults: root runtime, floating base tags like latest, secrets baked into ENV, no non-root user, no health check, and a bloated build context. Copilot produces a fast first draft; production readiness comes from review, a real build, an image scan (Trivy or Docker Scout), and a human decision. Run the same pipeline you would use for any pull request before an AI-written image reaches a registry or a cluster.

Can Copilot optimize Docker images?

Copilot can suggest optimizations — multi-stage builds, a slimmer base image, cache-friendly layer ordering, dropping unnecessary packages, and a tighter .dockerignore — and explain why each helps. But it cannot measure your image. Build before and after, compare with docker images and a scanner, and confirm the app still works. Copilot points at likely wins; docker build and the scan report tell you what actually changed. Do not trust size or vulnerability claims that no tool produced.

Can GitHub Copilot troubleshoot containers?

Copilot is genuinely useful for interpreting build errors and runtime failures — it can explain an exit code, a failed COPY, a DNS error, or a health check that never passes, and propose a hypothesis. It is not a substitute for the actual signals. Gather the evidence with docker ps -a, docker logs, docker inspect, and docker network inspect, give Copilot that context, and verify its explanation against what the commands report. Use it to narrow the search, not to pronounce the verdict.

Can Copilot improve Docker security?

Copilot can review a Dockerfile as a security checklist — flagging root runtime, floating tags, secrets in ENV, unnecessary packages, and exposed management ports — and suggest fixes. It is an additional review layer, not the authority. The authoritative check is an image scanner: Trivy (trivy image) or Docker Scout (docker scout cves) enumerate real CVEs in your base image and dependencies. Let Copilot explain findings and draft remediations; let the scanner and your review decide what ships.

Can Copilot create multi-stage Dockerfiles?

Yes, and it is one of the better uses. Ask Copilot to refactor a single-stage Dockerfile into a build stage plus a slim runtime stage, and it will separate build dependencies from the final image and copy only the artifacts you need. This usually reduces attack surface and often reduces size, but not always — a multi-stage build that still installs heavy runtime packages can be just as large. Build both versions and compare rather than assuming multi-stage always shrinks the image.

Can GitHub Actions automatically build Docker images?

Yes. A GitHub Actions workflow can check out the repo, run tests, build the image with Buildx, scan it, and push to a registry such as GHCR. Copilot drafts these workflows well using current actions (docker/setup-buildx-action, docker/metadata-action, docker/login-action, docker/build-push-action). Two safety rules: request least-privilege permissions (contents: read, packages: write), and do not push images built from untrusted pull request contexts. Confirm each action version against its repository.

Can GitHub Copilot help with Docker networking?

Yes. Copilot can explain default Compose networks, service-name DNS, custom networks, and the difference between published and internal ports. The key concept it reinforces: containers on the same Compose network reach each other by service name — for example postgres:5432 — not localhost, because localhost inside a container is that container itself. Verify connectivity with docker network inspect and by testing from inside a container with docker exec; use Copilot to explain what you observe.

Should secrets ever be stored in Dockerfiles?

No. Never put API keys, passwords, tokens, or private keys in a Dockerfile, and never bake them into an image with ENV — anyone who pulls the image can read them, and they persist in image layers. Use runtime environment variables, an untracked .env file kept out of version control and the build context, or a real secrets manager. In Dockerfiles and Compose files, show placeholders only. If Copilot inlines a literal secret, replace it before you build.

← Back to GitHub AI Engineering Academy

Related on DevOps AI Toolkit