Docker Intermediate #6: Logging and Debugging

9 min read

The final post of Docker Intermediate. Once you have several containers running, two questions hit first: where do logs come from, and where do you look when something doesn’t work. This post collects both.

This post in the Docker Intermediate series:

One-liner for container logging #

Docker’s recommended logging pattern is simple.

Apps log to stdout/stderr only. Docker takes the stream from there.

Don’t write to log files. Container filesystems are ephemeral (Basics #4), and aggregating logs from many containers requires the standard streams so tools can pick them up.

Once that habit is in place, docker logs, log drivers, and external aggregators like fluentd / Loki all flow through the same exit.

Quick notes per language #

Language / frameworkWhat to check
Pythonprint is buffered → set PYTHONUNBUFFERED=1. The logging module defaults to stderr.
Node.jsconsole.log/error go to stdout/stderr — fine
Golog.Println → stderr — fine
DjangoMake sure handlers in LOGGING are StreamHandler
NginxDefault is /var/log/nginx/... — the standard pattern is to redirect with access_log /dev/stdout; error_log /dev/stderr;

docker logs, again #

The command from Basics #3, now from an operations angle:

Common forms
docker logs -f myapp                      # follow
docker logs --tail 200 myapp              # last 200 lines
docker logs --since 30m myapp             # last 30 min
docker logs --since 2026-04-18T10:00 myapp  # absolute time
docker logs --until 1h myapp              # up to 1 hour ago
docker logs -t myapp                      # with timestamps

Combining --since and --until is great when you want to slice around an incident:

Slice around an incident
docker logs --since 14:00 --until 14:10 myapp

Where Docker stores stdout/stderr is the next step — log drivers.

Log driver — where the logs go #

Docker pipes container stdout/stderr through a log driver to wherever it’s configured. The default is json-file — JSON files on the host disk.

DriverWhere
json-file (default)/var/lib/docker/containers/<id>/<id>-json.log
localAn efficient json-file variant (compression and rotation by default)
journaldsystemd journal
syslogsyslog daemon
fluentdfluentd daemon (external aggregator)
gelfGraylog
awslogsAWS CloudWatch Logs
gcplogsGCP Cloud Logging
noneDiscard logs (docker logs won’t work either)

Per-container driver #

docker run
docker run -d --log-driver json-file \
  --log-opt max-size=10m \
  --log-opt max-file=3 \
  myapp
compose.yaml
services:
  web:
    image: myapp
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Those two options are essentially required in production:

  • max-size: 10m — rotate when one log file exceeds 10MB
  • max-file: 3 — keep at most 3, drop the rest

Without them, log files grow forever and fill the disk. One of the most common Docker incidents.

Daemon-wide defaults #

If setting these per service is annoying, set them on the daemon.

/etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Now every newly created container gets these defaults. Requires a daemon restart, so do it during a maintenance window in production.

docker compose logs — all together #

Seeing logs from many services in one stream is one of Compose’s big conveniences.

Compose log commands
docker compose logs                # everything
docker compose logs -f             # follow
docker compose logs --tail 100     # last 100 lines (per service)
docker compose logs web pg         # specific services
docker compose logs --since 10m -f web   # combined
docker compose logs --no-color     # no color (for file redirection)
docker compose logs --no-log-prefix web  # without "web | " prefix

Services get colored prefixes so you can tell them apart at a glance.

Sample output
web-1  | INFO     127.0.0.1 - - [20/May/2026 14:32:01] "GET / HTTP/1.1" 200
pg-1   | LOG:  database system is ready to accept connections
redis-1| 1:M 20 May 2026 14:32:00.123 # Server initialized

up --attach / --no-attach #

docker compose up lets you choose which services’ logs to follow.

Filter logs
docker compose up --attach web --attach worker     # only two services
docker compose up --no-attach pg --no-attach redis # exclude DB / cache

Useful when DB logs are too noisy or you want to focus on the service you’re debugging.

External log aggregation — one paragraph #

As the number of production containers grows, looking at host log files individually stops scaling. A one-paragraph survey:

  • Loki + Promtail + Grafana — lightweight self-hosted; can run as a compose bundle
  • Elastic Stack (ELK) — powerful but heavy: Elasticsearch + Logstash + Kibana
  • Datadog / New Relic / Grafana Cloud — managed SaaS
  • CloudWatch Logs / Cloud Logging — cloud-native environments

They all take container stdout, index it, and connect it to search / alerting. A topic for one step beyond this series, but worth knowing the names.

First debugging tool — docker exec #

A command from Basics #3, now from a debugging angle:

Inspect environment from inside the container
docker compose exec web sh

# inside
ps aux                    # process tree
env | sort                # env vars
cat /etc/resolv.conf      # DNS settings
ls -la /app               # working directory

docker compose exec lands you inside a running container. It doesn’t work for distroless / scratch bases — there’s no shell. (The trade-off from Intermediate #1.) For those, the pattern is to run a debug image alongside the target.

Debug container next to a distroless one
docker run -it --rm \
  --network container:myapp-web-1 \
  --pid container:myapp-web-1 \
  nicolaka/netshoot

--network container:X and --pid container:X share the target container’s network and process namespaces. Then commands inside nicolaka/netshoot (a network debug toolkit) like curl localhost:8000 reach the target’s port 8000. You loosen isolation and borrow the tools.

docker inspect — precision diagnosis #

For peering into JSON state.

Useful one-liners
# status
docker inspect myapp-web-1 --format '{{.State.Status}}'

# health
docker inspect myapp-pg-1 --format '{{json .State.Health}}' | jq

# network
docker inspect myapp-web-1 --format '{{json .NetworkSettings.Networks}}' | jq

# mounts
docker inspect myapp-web-1 --format '{{json .Mounts}}' | jq

# start time
docker inspect myapp-web-1 --format '{{.State.StartedAt}}'

# Was it OOM-killed?
docker inspect myapp-web-1 --format '{{.State.OOMKilled}}'

When you see OOMKilled: true, the container hit a memory limit — pair it with docker stats next.

docker stats — live resource usage #

CPU / memory / IO of every container
docker stats
# CONTAINER ID   NAME           CPU %   MEM USAGE / LIMIT     MEM %   NET I/O
# a1b2c3d4...    myapp-web-1    1.2%    120MiB / 7.7GiB       1.5%    12kB / 8kB
# e5f6g7h8...    myapp-pg-1     0.1%    35MiB / 7.7GiB        0.4%    5kB / 4kB

docker stats streams live numbers for every container by default. For a single snapshot:

One snapshot only
docker stats --no-stream

If you set resource limits, hitting 100% of MEM % will OOM-kill the container. In production, defining limits is standard.

compose.yaml — resource limits
services:
  web:
    image: myapp
    mem_limit: 512m
    cpus: 1.0
    # or the deploy key (Swarm) — for plain compose, the form above works

Disk usage — docker system df #

How much images / containers / volumes use:

One-line summary
docker system df
# TYPE           TOTAL   ACTIVE   SIZE      RECLAIMABLE
# Images         18      4        3.2GB     2.1GB (65%)
# Containers     6       1        12MB      5MB (41%)
# Local Volumes  8       3        420MB     280MB (66%)
Verbose
docker system df -v

Pair it with docker system prune from Basics #3 — your first command when disk fills up.

Looking inside an image — dive #

When an image is suspiciously large, or you want to see which layer holds what — the external tool dive is excellent.

Install dive (Homebrew)
brew install dive

dive myapp:latest

A TUI shows added / modified / deleted files per layer. Fastest way to spot what to slim. Sometimes used as a CI gate to prevent regression in image efficiency (build fails if a score drops below threshold).

docker compose top — processes inside containers #

Process tree inside
docker compose top
# myapp-web-1
# UID    PID    PPID    CMD
# root   1234   1200    python app.py
# root   1456   1234    python app.py (worker)

Same as ps aux after exec-ing in, but one command across all services. Quick way to spot zombie or unintended child processes.

docker events — Docker streaming what happened #

Occasional use for incident tracking / automation.

Live event stream
docker events --filter container=myapp-web-1
# 2026-04-18T14:32:01 ... container die ...
# 2026-04-18T14:32:01 ... container start ...

When a container keeps cycling up and down, this catches the pattern.

Common debugging flow #

SymptomFirst command
Container keeps exitingdocker logs <c>, docker inspect <c> --format '{{.State.OOMKilled}}'
Port not reachable from outsidedocker port <c>, confirm the app binds to 0.0.0.0 inside
Containers can’t talk to each otherSame network? (inspect), service name typo
Disk fulldocker system df, prune
Build is too bigdive, docker history
Compose isn’t merging as expecteddocker compose config
Env var didn’t applydocker compose run web env, the precedence table from #5

Wrapping the series #

The Intermediate toolkit, in one picture:

Tools map for Docker Intermediate
   Build efficiency               Run / operate
   ────────────────                ─────────────
   #1 Multi-stage                  #3 compose — multiple containers
   #2 BuildKit cache               #4 healthcheck, depends_on, profiles
   #2 mount cache                  #5 env vars and secrets
   #2 External caches              #6 logging and debugging

On top of the single-container cycle from Basics, Intermediate added many containers + operational sense: one compose file, one command; healthcheck-driven start order; secrets out of images; logs together; debugging at the ready.

The next series is Docker Advanced. Deeper BuildKit features, multi-architecture builds, image security (non-root, distroless, Trivy scans, SBOM, cosign signing), resource limits and cgroups, and the operational details of production. One step up on top of the compose foundation built here.

Wrap-up #

The picture from this post:

  • Apps log only to stdout/stderr; Docker handles the rest. No file logging.
  • docker logs --since/--until/--tail lets you slice by time / volume.
  • max-size + max-file log options are essential in production — they prevent disk runaway.
  • docker compose logs views many services at once with colored prefixes.
  • docker exec + inspect + stats are the first three debug tools. For distroless, the --network container: trick lets you bring a debug container alongside.
  • dive for inside images, docker system df + prune for disk, docker compose config for verifying merged definitions.
  • Internalize the first command for each common symptom and tracing becomes fast.
X