Logging & log drivers
Where container output goes, and how to ship it.
Every office building has a mailroom. You hand over a letter, and someone there decides whether it drops into a pigeonhole downstairs, goes into the building's internal mail system, or gets loaded onto a truck bound for another site. A Docker logging driver does that job for the lines your container prints. When a program inside a container writes to standard output or standard error (stdout and stderr, the two text streams every Unix program prints to), nothing catches those lines on its own. The Docker daemon (dockerd, the background service that actually runs your containers) picks them up and hands each one to the logging driver you configured. From then on, docker logs replays whatever that driver chose to keep. This is why apps in containers should print to stdout and stderr instead of writing to a file buried inside the container. The platform collects the stream it can see, and nothing else. The default driver is json-file, and it behaves perfectly well right up until the day it fills your disk.
$ docker logs -f --tail 50 web # follow the last 50 lines127.0.0.1 - - [16/Jul/2026:09:12:03 +0000] "GET /healthz HTTP/1.1" 200 2127.0.0.1 - - [16/Jul/2026:09:12:04 +0000] "GET /api/orders HTTP/1.1" 200 148$ docker inspect -f '{{.HostConfig.LogConfig.Type}}' webjson-file
json-file: the default that quietly fills the disk
json-file is named for exactly what it does. Every line your container prints becomes one JSON (JavaScript Object Notation, a plain-text format that stores data as labelled fields) object on its own line, written to a file under /var/lib/docker/containers/<id>/ and tagged with which stream it came from and when. docker logs reads straight out of that file, which is why you can follow output live and see character for character what the app wrote. The problem arrives later. On a stock install, json-file never rotates. No size cap. No cleanup. Ever. One chatty service stuck printing a stack trace in a loop can grow that file to tens of gigabytes over a weekend, and the moment /var/lib/docker is full, the daemon and every container on the node start failing in ways that look nothing like a logging problem. Pulls die. Containers refuse to start. Writes error out. This is the most common Docker logging incident by a wide margin. On a node that is already full, docker inspect --format '{{.LogPath}}' web prints the file doing the damage, and sudo truncate -s 0 on that path hands the space back immediately without stopping the container. Then set the two settings that keep it from happening again.
{"log-driver": "json-file","log-opts": {"max-size": "10m","max-file": "3"}}
Those two settings are max-size and max-file. Put them in daemon.json once and every container the engine starts from then on inherits bounded logs. max-size caps how large a single log file gets. max-file caps how many log files Docker keeps in total, the live one included, and it deletes the oldest each time a new rotation would push the count over. Ten megabytes across three files means the worst a runaway container can do is about 30 MB instead of the entire disk. Restart the daemon so it picks up the change, then prove it works. Flood a container with output and watch the files roll over.
$ sudo systemctl restart docker$ docker run -d --name noisy alpine \sh -c 'seq 1 2000000 | sed "s/^/flooding the log line /"'9c1f2a4b7e3d0a5c8b1e6f...$ ls -lh /var/lib/docker/containers/9c1f2a4b7e3d*/total 30M-rw-r----- 1 root root 10M Jul 16 09:20 9c1f...-json.log-rw-r----- 1 root root 10M Jul 16 09:20 9c1f...-json.log.1-rw-r----- 1 root root 10M Jul 16 09:20 9c1f...-json.log.2$ docker logs --tail 1 noisyflooding the log line 2000000
local: same place, better manners
Docker ships a second on-node driver called local, and it is the one you would reach for if json-file were not already wired in as the default. It stores logs in a compact binary format rather than JSON text, so the same output takes noticeably less room on disk. It also rotates by itself. Out of the box, local holds up to 100 megabytes spread over five 20-megabyte files and gzips (compresses) the rotated ones, so you get bounded, compressed logs without touching a single option. docker logs still works, because local keeps everything on the node the same way json-file does. Building a node from scratch? Setting local as the default is a cleaner starting point than capping json-file after the fact.
$ docker run -d --name svc --log-driver local \alpine sh -c 'seq 1 2000000 | sed "s/^/hello from local /"'b7e3d0a5c8b1e6f2a9d4c7...$ docker inspect -f '{{.HostConfig.LogConfig.Type}}' svclocal$ ls -lh /var/lib/docker/containers/b7e3d0a5c8b1*/local-logs/-rw-r----- 1 root root 20M Jul 16 09:34 container.log-rw-r----- 1 root root 1.2M Jul 16 09:34 container.log.1.gz-rw-r----- 1 root root 1.2M Jul 16 09:34 container.log.2.gz$ docker logs --tail 1 svchello from local 2000000
journald: let the host's journal hold it
On a host running systemd (the service manager that starts and supervises programs on most Linux distributions), you can skip Docker's own files and send container output to the system journal, the same place logs from sshd (the SSH login service) and cron (the scheduled job runner) already live. The driver is called journald. Once it is set, you read logs with journalctl and filter by container name or ID (identifier), so container output and host output sit in one queryable stream with real timestamps and structured fields. The journal enforces its own size limit and vacuums old entries, so it will not run away the way an uncapped json-file does. docker logs keeps working here too, because the journal lives on the same box.
$ docker run -d --name api --log-driver journald alpine \sh -c 'while true; do echo "api heartbeat $(date +%T)"; sleep 5; done'd0a5c8b1e6f2a9d4c7b3e0...$ journalctl CONTAINER_NAME=api -o cat --no-pager | tail -3api heartbeat 09:41:07api heartbeat 09:41:12api heartbeat 09:41:17$ docker logs --tail 1 apiapi heartbeat 09:41:17
Stdout is the contract
Docker hands the container a pipe rather than a terminal, and a runtime that buffers its output whenever it is not talking to a terminal will look completely silent under docker logs until a few kilobytes have piled up. Python is the usual offender, PYTHONUNBUFFERED=1 the usual fix. Write only to a file inside the container's writable layer and the lines are invisible to docker logs and to most collectors, unless you bolt on a sidecar container or a tailer running on the host to go and read them. json-file is what you get by default, and it will fill a disk unless max-size and max-file are set. journald, syslog, fluentd and the cloud drivers each trade a little local convenience for logs that live somewhere central.
During an incident, the first question to ask is where the driver put the bytes. docker logs reads the driver's store and nothing else, so if the app wrote to /var/log/app.log inside the container, those lines will not be there no matter how long you stare at the screen. Print structured single-line JSON to stdout when a parser has to read it, keep secrets out of log lines (they land on disk, in the journal and in the collector), and do not count on the driver to tidy up multiline stack traces. json-file, local and journald have no option for that, so a forty-line Java trace arrives as forty separate lines and it is your collector's parser that stitches them back together; awslogs is the rare exception, with awslogs-multiline-pattern. journald will also drop lines that arrive faster than its own rate limit, and you raise that in journald.conf on the host, not anywhere in Docker.
Choosing between an on-node driver and journald is a trade between what your operators already know and what stays identical across every node. Pick one standard for the platform, set it in daemon.json, and override it per container only for the genuinely noisy exceptions. When a disk alert fires on /var/lib/docker, unbounded container logs are a likelier cause than a pile of images.
Write the change down before you touch the node. Which driver was in force, which one you are moving to, the exact log-opts, and the one edit that puts it back. The docker inspect check shown above prints the driver for a running container, so paste the before and the after into the ticket. A driver change only reaches containers created after the daemon restart, so record which workloads were recreated and which are still running on the old setting. That list is what tells the next person whether a container with a swelling log file was missed in the rollout or left uncapped on purpose.
Try this
Run this on a lab engine (Docker 24 or newer is fine). The caps go on the container here rather than the daemon, so nothing else on the host changes. The container floods its own log on purpose, so you watch a rollover happen instead of taking the caps on trust.
$ docker run -d --name logdemo --log-opt max-size=1m --log-opt max-file=2 \alpine:3.20 sh -c 'seq 1 200000 | sed "s/^/logdemo line /"'4f2c9d1a8b3e07c5d2f6a...$ docker inspect logdemo --format '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}'json-file {"max-file":"2","max-size":"1m"}$ docker inspect logdemo --format '{{.LogPath}}'/var/lib/docker/containers/4f2c9d1a8b3e/4f2c9d1a8b3e-json.log$ sudo ls -lh /var/lib/docker/containers/4f2c9d1a8b3e/ | grep json.log # max-file=2 keeps two files, not three-rw-r----- 1 root root 1.0M Jul 16 09:52 4f2c9d1a8b3e-json.log-rw-r----- 1 root root 1.0M Jul 16 09:52 4f2c9d1a8b3e-json.log.1$ docker logs --tail 1 logdemologdemo line 200000
Takeaway
Print to stdout and stderr, cap json-file with max-size and max-file or move the node to local, and treat docker logs as a view of the driver's copy rather than the whole truth. The command worth keeping in your head is docker inspect -f '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}' on any node you have not touched before, because it answers in one line whether that box is still on the uncapped default.