Docker: Building and running containers

Nov 29, 2025

This cheatsheet grew out of a lab where I built and deployed an full stack application using Docker Compose.

#docker #container #cloud

Along the way I realized what’s actually happening inside container images. Not just running commands, but learning why certain patterns, flags, and tools matter.

This page collects the commands, best practices, and little insights I kept referring back to while building and debugging my setup.

Docker specific commands

#list all iamges
docker images

#Inspect build layer
docker history <image_name>

#Show running container stats
docker stats

#Get the image digest for image-pinning
docker buildx imagetools inspect traefik:3.6.2  
#Name:      docker.io/library/traefik:3.6.2  
#MediaType: application/vnd.oci.image.index.v1+json  
#Digest:    sha256:aaf0f6185419a50c746...2ddb7b8749eed505d55bf8b8ea

# Scan image for vulnerabilities
grype <image>

Dockerimages

RUN

Below is a well-formed RUN instruction that demonstrates all the apt-get recommendations.

# --no-install-recommends ensures your Dockerfile installs the latest package versions with no further coding or manual intervention. This technique is known as cache busting.
RUN apt-get update && apt-get install -y --no-install-recommends \
    aufs-tools \
    automake \
    build-essential \
    curl \
    dpkg-sig \
    libcap-dev \
    libsqlite3-dev \
    mercurial \
    reprepro \
    ruby1.9.1 \
    ruby1.9.1-dev \
    s3cmd=1.1.* \ # Cache busting by version pinning
    && rm -rf /var/lib/apt/lists/* 
# In addition, when you clean up the apt cache by removing `/var/lib/apt/lists` it reduces the image size, since the apt cache isn't stored in a layer. Since the `RUN` statement starts with `apt-get update`, the package cache is always refreshed prior to `apt-get install`.

Using pipes with RUN

Docker executes these commands using the /bin/sh -c interpreter. In this example the build succeeds as long as the wc -l commands succeeds, even if the wget command fails. RUN wget -O - https://some.site | wc -l > /number

If you want the command to fail due to an error at any stage in the pipe, prepend set -o pipefail && RUN set -o pipefail && wget -O - https://some.site | wc -l > /number

[!NOTE]

Not all shells support the -o pipefail option.

In cases such as the dash shell on Debian-based images, consider using the exec form of RUN to explicitly choose a shell that does support the pipefail option. For example:

RUN ["/bin/bash", "-c", "set -o pipefail && wget -O - https://some.site | wc -l > /number"]

Add non-root User

If a service can run without privileges, use USER to change to a non-root user. Start by creating the user and group in the Dockerfile with something like the following: RUN groupadd -r postgres && useradd --no-log-init -r -g postgres postgres

[!NOTE] Most images come with a nonroot user

COPY / bind

to temporarily add a requirements.txt file for a RUN pip install instruction:

RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \
    pip install --requirement /tmp/requirements.txt

Bind mounts are more efficient than COPY for including files from the build context in the container. Note that bind-mounted files are only added temporarily for a single RUN instruction, and don’t persist in the final image. If you need to include files from the build context in the final image, use COPY.

Docker architecture example

I’ve succesfully deployed the given application. All processes inside the containers run rootless.

Go Backend Dockerfile

#ARG GO_VERSION=1.25
# Build
# I choose the base version, because golang stated themselves that the -alpine version is not fully supported
# Also if I have the golang base image already in my cache the building time is the same
# And I think in an real-world example you would choose the base image for compatability reason
# And since we are multi-staging it doesn't affect the size of the final image
#FROM golang:${GO_VERSION} AS build
FROM golang@sha256:698183780de28062f4ef46f82a79ec0ae69d2d22f7b160cf69f71ea8d98bf25d AS build

WORKDIR /app

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

COPY *.go ./

RUN CGO_ENABLED=0 go build -a -o ./bin/api


FROM gcr.io/distroless/static-debian12@sha256:87bce11be0af225e4ca761c40babb06d6d559f5767fbf7dc3c47f0f1a466b92c AS production

COPY --from=build --chown=nonroot:nonroot /app/bin/api ./

USER nonroot:nonroot

EXPOSE 8000

CMD ["./api"]

# This would be the standard command if no argument is given.
# Since we just call the api and give the argument als env-variables I had to uncomment this for it to work
# I'm leaving this for my personal reference
#CMD ["help"]
#ENTRYPOINT ["./api"]

NodeJS Frontend Dockerfile

This Dockerfile is intentionally overengineered (lol) for this application.

#ARG NODE_VERSION=22.21.1

# Base image
#FROM node:${NODE_VERSION}-alpine AS base
FROM node@sha256:b2358485e3e33bc3a33114d2b1bdb18cdbe4df01bd2b257198eb51beb1f026c5 AS base

WORKDIR /app

# Dependencies
# Runs only when package.json changes
FROM base AS deps

COPY package*.json ./

# info: npm ci deletes the node_modules folder and does install packages from package-lock.json
# in that way it guarantees to install the exact applications that also succeeded in testing
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci --omit=dev && \
    npm cache clean --force

# Copy source files (runs only when source files change or layer deps gets rebuilded)
# This step is not really necessary since we dont have a testing stage
FROM base AS build

COPY src ./


# Production
FROM gcr.io/distroless/nodejs22-debian12@sha256:4c4b23e6694fa5a5081f79f94ad1c272fb7ff5c4a9609edf228e5e39492543b5 AS prod

COPY --from=deps --chown=nonroot:nonroot /app ./

COPY --from=build --chown=nonroot:nonroot /app ./

USER nonroot:nonroot

EXPOSE 8080

# Best practice ist to avoid npm scripts to CMD, because they dont pass OS Signals to the code.
# This prevents problems with child-process, signal handling, graceful shutdown and having processes.
CMD ["./bin/www"]


# I cancelled this part because our program is way to small to implement testing in it
########################
# Test the programm
# FROM build AS test
#RUN npm run test (run some tests)
# Test if the build breaks with explicit exit 1 call
#RUN exit 1
#RUN echo fake-test-pass
#########################



# For Debian/Ubuntu based base images because useradd and groupadd is the underlaying function when you call addgroup or adduser so this would be the nicer way to do it
###
#RUN groupadd -g 1001 app && \
#    useradd -u 1001 -g app app \
#    chown -R app:app /app
###

Docker Compose

services:
  traefik:
    #image: traefik:3.6.2
    image: traefik@sha256:aaf0f6185419a50c74651448c1a5bf4606bd2d2ddb7b8749eed505d55bf8b8ea
    user: "999:988"
    container_name: traefik
    security_opt:
      - no-new-privileges:true
    restart: unless-stopped
    networks:
      - traefik_net

    ports:
      - "80:80"
      - "443:443"

    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./certs:/certs:ro

    command:
      # EntryPoints
      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--entrypoints.web.http.redirections.entrypoint.permanent=true"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.websecure.http.tls=true"

      # Providers
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"

    labels:
      # Enable self‑routing
      - "traefik.enable=true"

    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: "256M"

  web:
    build: ./web
    container_name: tta-web
    restart: always
    networks:
      - traefik_net
      - web_api_net
    environment:
      MODULE_NAME: "web"
      GROUP_NAME: "g14"
      API_HOST: "api"
      API_PORT: "8000"
      PORT: "8080"    
    labels:
      - "traefik.http.routers.web.rule=Host(`cldinf-docker-g14.network.garden`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.web.tls=true"
      - "traefik.enable=true"
    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: "256M"


  api:
    build: ./api
    container_name: tta-api
    depends_on:
      - db
    restart: always
    environment:
      PG_USER_FILE: /run/secrets/db_user
      PG_PASSWORD_FILE: /run/secrets/db_password
      PG_DATABASE: "dbtta"
      PG_HOST: "db"
      PG_PORT: "5432"
    networks:
      - web_api_net
      - api_db_net
    secrets:
      - db_password
      - db_user
    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: "256M"


  db:
    #image: postgres:18.0-alpine
    image: postgres@sha256:48c8ad3a7284b82be4482a52076d47d879fd6fb084a1cbfccbd551f9331b0e40
    user: "70:70"
    restart: always
  #  shm_size: 256mb
    environment:
      POSTGRES_USER_FILE: /run/secrets/db_user
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_DB: "dbtta"
    volumes: 
      - db_data:/var/lib/postgresql/data
    networks:
      - api_db_net
    secrets:
      - db_password
      - db_user
    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: "256M"

secrets:
  db_password:
    file: ./secrets/db_password.txt
  db_user:
    file: ./secrets/db_user.txt

networks:
  traefik_net:
    name: traefik_net
  web_api_net:
    name: web_api_net
  api_db_net:
    name: api_db_net

volumes:
  db_data: