BytePane

How to Use Docker: Containers for Beginners

DevOps18 min read

Key Takeaways

  • Docker ranked #1 in the Stack Overflow 2025 Developer Survey — 71.1% of professional developers use it, a 17-point year-over-year increase.
  • A container is not a VM: it shares the host kernel, starts in milliseconds, and uses ~10x less memory than a VM running the same app.
  • Images are layered and immutable; containers are running instances. Understanding this distinction prevents 80% of beginner confusion.
  • Docker Compose is the standard for local multi-service development — one YAML file replaces a page of docker run commands.
  • The Docker container market reached $6.12 billion in 2025 and is projected to hit $16.32 billion by 2030 at 21.67% CAGR (MarketsandMarkets 2025).

The Biggest Misconception About Docker

"Docker is just a lightweight VM." This is the most common misconception, and it causes endless confusion about what Docker actually does and when to use it.

A virtual machine emulates hardware, boots a full operating system kernel, and consumes gigabytes of RAM before your application even starts. A Docker container does none of this. It is a process with an isolated filesystem, network, and process namespace — sharing the host OS kernel directly. The distinction is not academic:

PropertyVirtual MachineDocker Container
Boot time30–60 seconds~50 milliseconds
Disk footprint5–20 GB per VM50–500 MB per image
RAM overhead512 MB – 2 GB (OS)~5 MB (shared kernel)
Kernel isolationComplete (separate kernel)Shared host kernel
Run multiple instancesExpensive (GB RAM each)Cheap (MB RAM each)
Cross-OS supportAny OS on any hostLinux containers on Linux (Docker Desktop bridges on Mac/Windows)

This performance gap explains why Docker hit #1 in the Stack Overflow 2025 Developer Survey — the first time Docker topped the tools category — with 71.1% professional developer adoption, up 17 points year-over-year. When your CI pipeline can spin up a fresh environment in 50ms instead of 60 seconds, iteration speed fundamentally changes.

Core Concepts: Images, Containers, and Layers

Docker's mental model has three building blocks. Confuse them and nothing makes sense. Understand them and everything clicks.

Images: The Blueprint

A Docker image is a read-only, layered filesystem. Each layer represents a set of filesystem changes — a new file, a deleted file, a modified permission. Layers are content-addressed by SHA256 hash and shared across images. If two images both derive from node:20-alpine, they share that base layer on disk and in memory. Docker Hub serves over 13 billion container image pulls per month, according to Docker's 2025 State of Application Development Report.

You define an image with a Dockerfile — a plain text file of instructions. Here is a production-ready Dockerfile for a Node.js application using multi-stage builds to keep the final image small:

# Stage 1: install dependencies (separate stage keeps build tools out of prod image)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: production image — only runtime, no devDependencies or npm cache
FROM node:20-alpine AS runner
WORKDIR /app

# Run as non-root user — critical security practice
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

COPY --from=deps /app/node_modules ./node_modules
COPY --chown=appuser:appgroup . .

# EXPOSE documents the port — doesn't actually publish it
EXPOSE 3000

# CMD is the default; can be overridden at runtime
CMD ["node", "server.js"]

Containers: The Running Instance

A container is a running process spawned from an image, with an additional writable layer on top. When you write files inside a container, they go into this writable layer — which disappears when the container is removed. This is why databases need volumes (covered later). Multiple containers can run from the same image simultaneously; they share the read-only image layers but each has its own isolated writable layer.

The Registry: Docker Hub and Beyond

Images are stored in registries. Docker Hub is the default public registry — it hosts official images for Postgres, Redis, Nginx, Node.js, and thousands more. Pull an image with docker pull, or reference it directly in your Dockerfile's FROM instruction. Production systems typically use private registries: AWS ECR, Google Artifact Registry, or GitHub Container Registry (ghcr.io), which keep your images private and co-located with your infrastructure.

Installing Docker and Running Your First Container

Docker Desktop (Mac and Windows) bundles the Docker daemon, CLI, and a lightweight Linux VM. On Linux, install Docker Engine directly — no VM needed because the kernel is native.

# Linux: install Docker Engine (Ubuntu/Debian)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER   # add yourself to the docker group
newgrp docker                   # activate without logging out

# Verify installation
docker version
docker info

# Run your first container — the classic Hello World
docker run hello-world

# Output: "Hello from Docker! This message shows that your installation
#          appears to be working correctly."

# More useful: run an interactive Alpine Linux shell (30 MB image)
docker run -it --rm alpine sh
# -it  = interactive TTY
# --rm = remove container after exit
# Now you are inside a container — try: cat /etc/os-release

The docker run command does four things in sequence: checks local image cache, pulls from Docker Hub if not cached, creates a new container from the image, and starts it. The entire sequence for hello-world completes in under a second on any modern machine.

Essential Docker Commands Every Developer Uses Daily

A handful of commands covers 90% of day-to-day Docker work. The rest exists for edge cases you will look up when needed.

# ─── IMAGES ──────────────────────────────────────────────────
docker images                   # list local images
docker pull nginx:1.27-alpine   # pull specific version (ALWAYS pin versions in prod)
docker build -t myapp:1.0 .     # build from Dockerfile in current directory
docker rmi myapp:1.0            # remove image
docker image prune              # remove dangling (untagged) images

# ─── CONTAINERS ───────────────────────────────────────────────
docker ps                       # running containers
docker ps -a                    # all containers (including stopped)
docker run -d -p 8080:80 nginx  # run nginx, detached, port 8080 → 80
docker stop <name_or_id>        # graceful stop (SIGTERM)
docker kill <name_or_id>        # immediate stop (SIGKILL)
docker rm <name_or_id>          # remove stopped container
docker rm -f <name_or_id>       # force remove running container

# ─── DEBUGGING ────────────────────────────────────────────────
docker logs <name_or_id>        # stdout/stderr from container
docker logs -f <name_or_id>     # follow logs (like tail -f)
docker exec -it <name> sh       # open shell inside running container
docker inspect <name_or_id>     # full JSON metadata (ports, mounts, env vars)
docker stats                    # live CPU/RAM/network usage per container

# ─── CLEANUP ──────────────────────────────────────────────────
docker system prune             # remove stopped containers + dangling images
docker system prune -a          # also remove unused images (reclaims GB of disk)

The single most useful debugging command is docker exec -it <name> sh. It opens an interactive shell inside a running container so you can inspect the filesystem, check environment variables, and run commands as if you SSH'd in.

Ports, Volumes, and Environment Variables

Three mechanisms control how containers interact with the outside world.

Port Mapping: Exposing Container Ports

Containers have their own network namespace. To access a service inside a container from your host, you map a host port to a container port with -p host:container.

# Map host port 5432 to container port 5432 (PostgreSQL)
docker run -d   --name mypostgres   -p 5432:5432   -e POSTGRES_PASSWORD=secret   -e POSTGRES_DB=myapp   postgres:16-alpine

# Now connect from host: psql -h localhost -p 5432 -U postgres myapp

# Multiple ports: run a dev Node.js server on 3000 + debugger on 9229
docker run -d -p 3000:3000 -p 9229:9229 myapp:dev

Volumes: Persisting Data Across Container Restarts

Containers are ephemeral — their writable layer vanishes on removal. For persistent data (databases, uploads, logs), use volumes. Docker manages named volumes; bind mounts map a specific host path.

# Named volume — Docker manages it (preferred for databases)
docker run -d   --name mypostgres   -v pgdata:/var/lib/postgresql/data   -e POSTGRES_PASSWORD=secret   postgres:16-alpine

# pgdata is stored in /var/lib/docker/volumes/pgdata/
# Survives container removal — use docker volume rm pgdata to delete

# Bind mount — maps host directory to container path (great for dev hot-reload)
docker run -d   -v $(pwd)/src:/app/src   -p 3000:3000   myapp:dev
# Changes to ./src on your host are instantly visible inside the container

Environment Variables: Configuration Without Rebuilding

Never bake secrets into Docker images. Pass configuration via environment variables using -e flags or an --env-file pointing to a .env file. This is the twelve-factor app principle applied to containers: the same image runs in dev, staging, and production with different environment variables.

# Using --env-file (never commit .env to git)
docker run -d --env-file .env.production -p 3000:3000 myapp:latest

# .env.production contents:
# NODE_ENV=production
# DATABASE_URL=postgres://user:pass@db:5432/myapp
# SECRET_KEY=...

# View environment inside a running container
docker exec mycontainer env

Docker Compose: Multi-Container Local Development

Running a single container is useful. Running a web server, database, cache, and message queue together — with networking, volumes, and startup order — is where Docker Compose shines. According to Docker's 2025 State of App Development Report, 64% of developers now use non-local primary development environments, and Docker Compose is the dominant tool for orchestrating those local environments.

Here is a production-representative docker-compose.yml for a Node.js API with PostgreSQL and Redis:

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:secret@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    volumes:
      - ./src:/app/src        # hot-reload: changes sync to container
    depends_on:
      db:
        condition: service_healthy  # wait for postgres to be ready
      cache:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql  # seed data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    command: redis-server --save 60 1 --loglevel warning

volumes:
  pgdata:
    driver: local
# Essential Compose commands
docker compose up               # start all services (attached)
docker compose up -d            # start detached (background)
docker compose down             # stop and remove containers (volumes preserved)
docker compose down -v          # also remove volumes (fresh slate)
docker compose logs -f api      # follow logs for the 'api' service
docker compose exec api sh      # shell into running 'api' container
docker compose ps               # status of all services
docker compose build --no-cache # force rebuild all images

Notice the healthcheck on the database service and condition: service_healthy on the API's dependency. Without this, the API container starts before PostgreSQL accepts connections, causing connection errors on the first few requests. This is the source of probably 30% of "Docker Compose doesn't work" complaints. For a complete deep-dive into local dev workflows, see our Docker Compose local development guide.

Building Smaller, Faster Images

Image size directly impacts pull time in CI, startup time in production, and your registry storage bill. Five techniques that have an outsized impact:

1. Use Alpine or Distroless Base Images

The official node:20 image is 1.1 GB. node:20-alpine is 68 MB. Google's Distroless images strip out the shell, package manager, and everything except the runtime — a Node.js Distroless image is ~40 MB and eliminates most shell-injection attack surfaces.

2. Multi-Stage Builds

Shown in the Dockerfile above: build in one stage, copy only artifacts to the final stage. A TypeScript project needs the compiler and all devDependencies to build; the production image needs only the compiled JavaScript and production dependencies. Multi-stage builds give you the tooling during build without the bloat at runtime.

3. Layer Cache Ordering

Docker rebuilds layers from the first changed instruction onward. Copy your package.json and run npm ci before copying source code. This way, the expensive dependency installation layer is cached and reused across builds unless dependencies actually change — typically cutting CI build time by 60–80%.

4. .dockerignore

# .dockerignore — exclude from build context
node_modules
.git
.env*
*.log
dist
coverage
.DS_Store

Without .dockerignore, node_modules (often 300–500 MB) gets transferred to the Docker daemon on every build even if you later overwrite it in the container. This wastes time and risks bundling platform-specific native binaries into the image.

5. Pin Image Versions

FROM node:latest in production is a supply chain vulnerability waiting to happen. A new tag can introduce breaking changes, security patches that change behavior, or subtle OS differences. Pin to a specific version: FROM node:20.14.0-alpine3.20. Update deliberately, test, then advance the pin.

Container Networking: How Containers Talk to Each Other

Docker creates a virtual network by default. Containers on the same Docker network can reach each other by service name — which is how DATABASE_URL=postgres://postgres:secret@db:5432 works in Compose: db is the service name, resolved by Docker's internal DNS.

# Docker Compose creates a default bridge network named <project>_default
# All services on it can reach each other by service name

# Manual networking (without Compose)
docker network create mynet
docker run -d --name db --network mynet postgres:16-alpine
docker run -d --name api --network mynet -e DB_HOST=db myapp:latest
# 'api' can reach 'db' at hostname 'db' on port 5432

# Inspect network
docker network ls
docker network inspect mynet

# Host networking (Linux only) — container uses host network stack directly
# Best for high-performance scenarios or tools that need to see all host traffic
docker run --network host myapp

Docker in CI/CD Pipelines

Docker's reproducibility is the reason it dominates CI. A container is the same in development, CI, and production — no "works on my machine" class of bugs. Here is a GitHub Actions workflow that builds, tests, and pushes an image:

# .github/workflows/docker.yml
name: Build and Push Docker Image

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3  # enables BuildKit and multi-platform builds

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha       # GitHub Actions cache for layer reuse
          cache-to: type=gha,mode=max

The cache-from: type=gha line is the difference between a 5-minute and 45-second CI build. It stores Docker layer cache in GitHub Actions cache storage, so subsequent builds only rebuild changed layers. For a complete CI/CD pipeline guide, including testing stages and deployment strategies, see our dedicated article.

Docker Security: The Non-Negotiables

The Docker ecosystem has a security problem: too many tutorials run containers as root, expose the Docker socket, and pull unverified images. Per Snyk's 2025 Open Source Security Report, 84% of Docker images on Docker Hub contain at least one medium-severity vulnerability. Four practices eliminate the most critical exposure:

  • 01

    Run as non-root user

    Add USER appuser to your Dockerfile. A container running as root can escape to root on the host under certain conditions. This is the single highest-impact change for container security.

  • 02

    Never mount the Docker socket

    Mounting -v /var/run/docker.sock:/var/run/docker.sock gives the container full Docker daemon access — equivalent to host root. Use Docker-in-Docker (DinD) with TLS or rootless Docker instead.

  • 03

    Scan images before pushing

    Run docker scout cves myimage (Docker Scout, formerly Snyk integration) or trivy image myimage (Trivy, open source) to surface CVEs in your base image and dependencies before they reach production.

  • 04

    Use read-only filesystems where possible

    Run containers with --read-only to prevent malicious code from writing to the container filesystem. Mount tmpfs for temporary files: --tmpfs /tmp.

For broader container best practices including resource limits, capability dropping, and seccomp profiles, see our Docker container best practices guide.

Frequently Asked Questions

What is the difference between a Docker image and a container?
An image is a read-only blueprint — layered filesystem plus metadata. A container is a running process instantiated from that image, with its own writable layer. You can run dozens of containers from one image simultaneously. Think of it as class vs. instance — the image is the class, the container is the object.
Is Docker the same as a virtual machine?
No. A VM boots a full OS kernel (2–4 GB overhead, 30–60 second boot). A Docker container shares the host kernel and starts in milliseconds with ~5 MB overhead. The trade-off: containers have weaker kernel isolation than VMs. For most app workloads, containers are the right choice; for untrusted code execution, VMs offer stronger isolation.
What is Docker Compose and when should I use it?
Docker Compose lets you define multi-container applications in a single YAML file. Use it whenever your app needs more than one service locally — a Node.js API with PostgreSQL and Redis is the standard case. One docker compose up starts everything with correct networking, volumes, and startup order.
What is a Dockerfile?
A Dockerfile is a text file of ordered instructions Docker uses to build an image. FROM sets the base image, RUN executes commands during build, COPY transfers files into the image, and CMD sets the default startup command. Each instruction creates a layer; layers are cached and reused across builds.
How do I persist data in Docker containers?
Containers are ephemeral — their writable layer disappears on removal. For persistent data, use named volumes (docker run -v pgdata:/var/lib/postgresql/data) or bind mounts (map a host directory into the container). Named volumes are preferred for databases; bind mounts are preferred for development hot-reload.
What is the difference between CMD and ENTRYPOINT?
ENTRYPOINT is the fixed executable that always runs. CMD provides default arguments that can be overridden at runtime. Use ENTRYPOINT when the container wraps a specific tool. When both are set, CMD supplies default arguments to ENTRYPOINT — override CMD by passing different arguments to docker run.
How do I see running Docker containers and stop them?
docker ps lists running containers. docker stop <name> sends SIGTERM (graceful) and SIGKILL after 10 seconds. docker rm removes a stopped container. To see all containers including stopped: docker ps -a. Clean up everything: docker system prune.

Developer Tools for Docker Workflows

Docker configs use YAML and JSON extensively. BytePane's free tools help you validate and inspect them:

  • JSON Formatter — validate and pretty-print Docker inspect output and config JSON
  • YAML Validator — catch docker-compose.yml syntax errors before running docker compose up
  • Base64 Encoder/Decoder — decode Docker registry credentials and secret values
Validate Your docker-compose.yml

Related Articles