So you spun up a few Docker containers. Everything’s working. Life is good. Then something breaks at 2 AM and you think, “I’ll just check the logs.” Thirty minutes later, you’re questioning every life decision that led you to this moment, because your logs are either gone, incomprehensible, or scattered across seventeen different places.
Welcome to the wonderful world of Docker logging.
Don’t worry — by the end of this article, you’ll go from “where did my logs go?” to having a centralized, searchable, rotated, and (dare I say) pleasant logging setup. Let’s get into it.
The Basics: docker logs and How Containers Actually Log
Before we get fancy, let’s understand the fundamentals. Docker containers, by default, capture anything your application writes to stdout and stderr. That’s it. There’s no magic log file inside the container (well, unless your app creates one, but we’ll get to that antipattern later).
To see these logs, you use the aptly named command:
docker logs <container_name_or_id>Some handy flags you’ll use constantly:
# Follow logs in real-time (like tail -f)docker logs -f my-container
# Show last 100 linesdocker logs --tail 100 my-container
# Show logs since a specific timedocker logs --since 2024-01-15T10:00:00 my-container
# Show timestampsdocker logs -t my-container
# Combine them -- last 50 lines, with timestamps, followingdocker logs -f -t --tail 50 my-containerThis works perfectly when you have one or two containers running on a single machine. But the moment you scale beyond that — or the moment your container restarts and you realize the old logs might be gone — things get spicy.
Pro tip: If your application logs to a file inside the container instead of stdout/stderr, docker logs will show you absolutely nothing. This is the number one “where did my logs go?” moment for Docker beginners. The fix? Configure your app to log to stdout. In most frameworks, this is a one-line config change. Do it. Future-you will thank present-you.
Logging Drivers: The Engine Behind the Curtain
Here’s where Docker gets interesting. Behind every docker logs command is a logging driver — the mechanism Docker uses to handle your container’s output. Think of it like choosing where your mail gets delivered. Same letters, different mailbox.
Docker supports several logging drivers, and choosing the right one matters more than you think.
json-file (The Default)
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }}This is what Docker uses out of the box. It writes logs as JSON to files on disk, typically found at /var/lib/docker/containers/<container-id>/<container-id>-json.log.
Pros: Simple, works with docker logs, human-readable.
Cons: No built-in centralization, can eat your disk alive if you don’t configure rotation (more on that in a minute).
syslog
{ "log-driver": "syslog", "log-opts": { "syslog-address": "udp://logs.example.com:514", "syslog-facility": "daemon", "tag": "{{.Name}}" }}Sends logs to a syslog server. If your organization already has a syslog infrastructure, this is a natural fit. It speaks the language your ops team already knows.
Pros: Integrates with existing syslog infrastructure, well-understood protocol.
Cons: docker logs command stops working (yep, really), UDP can lose messages, limited structured data support.
fluentd
{ "log-driver": "fluentd", "log-opts": { "fluentd-address": "localhost:24224", "tag": "docker.{{.Name}}" }}Routes logs to a Fluentd or Fluent Bit collector. This is where things start getting professional. Fluentd can parse, filter, transform, and ship your logs basically anywhere.
Pros: Extremely flexible, supports hundreds of output plugins, great for centralized setups. Cons: Requires running a Fluentd/Fluent Bit instance, slightly more complex setup.
Other Notable Drivers
- journald: Ships to systemd journal. Great for systemd-based hosts.
- gelf: Sends to Graylog Extended Log Format endpoints. Useful if you’re a Graylog shop.
- awslogs: Ships directly to AWS CloudWatch. If you’re on AWS, this is a no-brainer.
- gcplogs: Same idea, but for Google Cloud Logging.
- local: Similar to json-file but uses an internal storage format that’s more space-efficient. Supports
docker logs. A solid upgrade from the default if you want to stay simple. - none: Disables logging entirely. For when you really, truly don’t care. (You probably care.)
Important gotcha: When you switch away from json-file or local, the docker logs command stops working for most drivers. This catches people off guard constantly. Plan accordingly.
Log Rotation: Stop Your Disk From Committing Seppuku
Here’s a horror story I’ve seen play out more times than I’d like to admit: a production server runs out of disk space. Everything grinds to a halt. Databases crash. Alerts fire. Someone SSHs in and discovers a single Docker container has been writing logs for six months straight, and there’s now a 47GB JSON file sitting in /var/lib/docker/containers/.
Don’t be that person.
Configuring Log Rotation
For the default json-file driver, add rotation settings. You can do this per-container or globally.
Per-container (in docker run):
docker run \ --log-driver json-file \ --log-opt max-size=10m \ --log-opt max-file=5 \ my-app:latestGlobally (in /etc/docker/daemon.json):
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "5", "compress": "true" }}After editing daemon.json, restart Docker:
sudo systemctl restart dockerThis gives you 5 rotated log files, each max 10MB, compressed. That’s a maximum of ~50MB per container instead of infinity-and-beyond.
What About the local Driver?
The local driver handles rotation by default and is more efficient with disk space:
{ "log-driver": "local", "log-opts": { "max-size": "10m", "max-file": "3" }}It uses a compressed internal format, so your 10MB files actually hold more log data than the equivalent json-file configuration. If you’re running a single-host setup and just want logs to behave themselves, local is a solid pick.
Sizing Guidelines
Here’s a rough cheat sheet for rotation settings:
| Environment | max-size | max-file | Notes |
|---|---|---|---|
| Dev/local | 5m | 2 | Minimal, just enough to debug |
| Staging | 10m | 3 | Moderate, mirrors prod-ish behavior |
| Production (centralized) | 10m | 5 | Buffer while logs ship to central store |
| Production (no centralization) | 50m | 10 | You’ll want more local history |
Docker Compose Logging Configuration
If you’re using Docker Compose (and you probably should be for anything beyond a single container), logging configuration lives right in your docker-compose.yml:
version: "3.8"
services: web: image: nginx:latest logging: driver: json-file options: max-size: "10m" max-file: "5" tag: "{{.Name}}"
api: image: my-api:latest logging: driver: json-file options: max-size: "10m" max-file: "5" tag: "{{.Name}}"
worker: image: my-worker:latest logging: driver: fluentd options: fluentd-address: "localhost:24224" tag: "app.worker"Notice you can mix and match drivers per service. Maybe your web proxy uses json-file because you want quick docker logs access, while your worker ships to Fluentd for centralized processing. Totally valid.
YAML Anchors for DRY Logging Config
If you’re applying the same logging config to multiple services (you usually are), use YAML anchors to keep things clean:
x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "5" tag: "{{.Name}}"
services: web: image: nginx:latest logging: *default-logging
api: image: my-api:latest logging: *default-logging
worker: image: my-worker:latest logging: *default-loggingChef’s kiss. Clean, maintainable, and you only have to change it in one place.
Centralized Logging with Loki + Grafana
Alright, let’s graduate from “I can see my logs on one machine” to “I can see ALL my logs from EVERYWHERE in one beautiful dashboard.” Enter the Loki + Grafana stack.
Why Loki?
If you’ve heard of the ELK stack (Elasticsearch, Logstash, Kibana), Loki is like its lean, mean, resource-efficient cousin. Created by Grafana Labs, Loki was specifically designed for log aggregation and pairs perfectly with Grafana for visualization.
Key differences from ELK:
- Loki doesn’t index the full text of your logs. It only indexes labels (metadata). This makes it dramatically cheaper to run.
- Queries use LogQL, which feels natural if you’re used to Prometheus’s PromQL.
- It’s designed for Docker and Kubernetes from the ground up.
Think of it this way: Elasticsearch is a library that catalogs every word in every book. Loki is a library that catalogs book titles and authors, then lets you read the full text when you need it. Way less overhead for most use cases.
Setting Up the Stack
Here’s a complete Docker Compose setup for Loki + Grafana + Promtail (Loki’s log shipping agent):
version: "3.8"
services: loki: image: grafana/loki:2.9.0 ports: - "3100:3100" volumes: - ./loki-config.yml:/etc/loki/local-config.yaml - loki-data:/loki command: -config.file=/etc/loki/local-config.yaml
promtail: image: grafana/promtail:2.9.0 volumes: - ./promtail-config.yml:/etc/promtail/config.yml - /var/log:/var/log - /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/run/docker.sock:/var/run/docker.sock command: -config.file=/etc/promtail/config.yml depends_on: - loki
grafana: image: grafana/grafana:latest ports: - "3000:3000" volumes: - grafana-data:/var/lib/grafana environment: - GF_SECURITY_ADMIN_PASSWORD=changeme depends_on: - loki
volumes: loki-data: grafana-data:Loki Configuration
Create loki-config.yml:
auth_enabled: false
server: http_listen_port: 3100
common: path_prefix: /loki storage: filesystem: chunks_directory: /loki/chunks rules_directory: /loki/rules replication_factor: 1 ring: kvstore: store: inmemory
schema_config: configs: - from: 2020-10-24 store: tsdb object_store: filesystem schema: v13 index: prefix: index_ period: 24h
limits_config: reject_old_samples: true reject_old_samples_max_age: 168h # 7 days max_query_length: 721h
storage_config: filesystem: directory: /loki/storage
compactor: working_directory: /loki/compactorPromtail Configuration
Create promtail-config.yml:
server: http_listen_port: 9080 grpc_listen_port: 0
positions: filename: /tmp/positions.yaml
clients: - url: http://loki:3100/loki/api/v1/push
scrape_configs: - job_name: docker docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s relabel_configs: - source_labels: ['__meta_docker_container_name'] regex: '/(.*)' target_label: 'container' - source_labels: ['__meta_docker_container_log_stream'] target_label: 'stream' - source_labels: ['__meta_docker_compose_service'] target_label: 'service'Using the Loki Docker Plugin (Alternative to Promtail)
If you’d rather skip Promtail entirely, you can install the Loki Docker logging driver plugin and ship logs directly from Docker:
docker plugin install grafana/loki-docker-driver:latest --alias loki --grant-all-permissionsThen configure it in daemon.json:
{ "log-driver": "loki", "log-opts": { "loki-url": "http://localhost:3100/loki/api/v1/push", "loki-batch-size": "400", "loki-retries": "2", "loki-max-backoff": "800ms", "loki-timeout": "1s" }}Or per-service in Docker Compose:
services: my-app: image: my-app:latest logging: driver: loki options: loki-url: "http://localhost:3100/loki/api/v1/push" loki-batch-size: "400" loki-retries: "2"Querying with LogQL
Once your logs are flowing into Loki, you can query them in Grafana using LogQL. Here are some examples to get you started:
# All logs from a specific container{container="my-api"}
# Filter by log content{container="my-api"} |= "error"
# Regex filter{container="my-api"} |~ "status=[45]\\d{2}"
# Parse JSON logs and filter by field{container="my-api"} | json | level="error"
# Count errors per minutecount_over_time({container="my-api"} |= "error" [1m])
# Top 5 containers by error counttopk(5, count_over_time({service=~".+"} |= "error" [5m]))LogQL is genuinely powerful once you get the hang of it. It’s like grep learned kung fu and got a visualization degree.
Fluentd and Fluent Bit Setup
Fluentd (and its lighter sibling, Fluent Bit) are the Swiss Army knives of log processing. They collect, parse, filter, and ship logs to basically any destination you can think of.
Fluentd vs. Fluent Bit: Which One?
| Feature | Fluentd | Fluent Bit |
|---|---|---|
| Language | Ruby + C | C |
| Memory footprint | ~40MB | ~450KB |
| Plugin ecosystem | Huge (900+) | Smaller but growing |
| Best for | Complex processing, many destinations | Edge collection, resource-constrained environments |
| Configuration | More flexible | Simpler |
Rule of thumb: Use Fluent Bit on each host to collect and do basic filtering, then ship to Fluentd for complex processing. Or just use Fluent Bit for everything if your pipeline is straightforward.
Fluent Bit Docker Setup
version: "3.8"
services: fluent-bit: image: fluent/fluent-bit:latest volumes: - ./fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf - /var/lib/docker/containers:/var/lib/docker/containers:ro ports: - "24224:24224" - "24224:24224/udp"
app: image: my-app:latest logging: driver: fluentd options: fluentd-address: "localhost:24224" tag: "app.{{.Name}}"Fluent Bit Configuration
Create fluent-bit.conf:
[SERVICE] Flush 1 Log_Level info Parsers_File parsers.conf
[INPUT] Name forward Listen 0.0.0.0 Port 24224
[FILTER] Name parser Match app.* Key_Name log Parser json Reserve_Data On
[FILTER] Name modify Match * Add hostname ${HOSTNAME} Add environment production
[FILTER] Name grep Match * Exclude log healthcheck
[OUTPUT] Name loki Match * Host loki Port 3100 Labels job=fluent-bit, app=$TAG Auto_Kubernetes_Labels off
[OUTPUT] Name stdout Match * Format json_linesThis config does several things:
- Accepts logs from Docker containers via the forward protocol
- Parses JSON log messages into structured fields
- Adds hostname and environment metadata
- Filters out healthcheck noise (because nobody needs 10,000 “GET /health 200” lines per hour)
- Ships to Loki for centralized storage
- Also prints to stdout for debugging
Fluentd Configuration (Full Setup)
If you need the full power of Fluentd:
<source> @type forward port 24224 bind 0.0.0.0</source>
<filter app.**> @type parser key_name log reserve_data true <parse> @type json </parse></filter>
<filter **> @type record_transformer <record> hostname "#{Socket.gethostname}" environment "#{ENV['ENVIRONMENT'] || 'development'}" </record></filter>
# Remove noisy health check logs<filter **> @type grep <exclude> key log pattern /healthcheck|health_check|GET \/health/ </exclude></filter>
# Route errors to a separate output for alerting<match app.**.error> @type copy <store> @type loki url "http://loki:3100" <label> job fluentd level error </label> </store> <store> @type slack webhook_url "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" channel "#alerts" username "Log Alert" message "%s" </store></match>
<match **> @type loki url "http://loki:3100" <label> job fluentd </label> <buffer> @type file path /fluentd/buffer flush_interval 5s chunk_limit_size 2M retry_max_interval 30 retry_forever true </buffer></match>Log Filtering: Separating Signal from Noise
Once you have centralized logging, you’ll quickly realize that 90% of your logs are noise. Health checks, routine operations, debug messages in production — it all adds up. Here’s how to tame it.
At the Application Level
The best filtering happens before logs even leave your app. Use appropriate log levels:
FATAL -> Something is catastrophically brokenERROR -> Something failed, needs attentionWARN -> Something unexpected, but handledINFO -> Normal operations worth notingDEBUG -> Detailed diagnostic info (never in production)TRACE -> Ultra-detailed (definitely never in production)At the Docker Level
Use the tag option to make logs identifiable:
logging: driver: json-file options: tag: "{{.ImageName}}/{{.Name}}/{{.ID}}"At the Collector Level (Fluent Bit Example)
# Drop debug logs in production[FILTER] Name grep Match * Exclude level debug
# Only keep errors from noisy services[FILTER] Name grep Match app.payment-service Regex level (error|fatal)
# Sample verbose logs (keep 1 in 10)[FILTER] Name throttle Match app.verbose-service Rate 10 Window 300 Print_Status trueIn LogQL (Grafana)
# Filter out healthchecks and metrics endpoints{service="api"} != "GET /health" != "GET /metrics"
# Only show errors with stack traces{service="api"} |= "error" |= "Traceback"
# Parse JSON and filter by response time > 1s{service="api"} | json | response_time > 1000Storage Considerations
Logs are deceptively expensive. Not in the “oops I left a GPU instance running” way, but in the slow-drip, “why is our S3 bill $800 this month?” way. Here’s what to think about.
Retention Policies
Set aggressive retention policies. Ask yourself: “When was the last time I needed a log from more than 30 days ago?” For most teams, the answer is never.
In Loki, configure retention in your config:
limits_config: retention_period: 720h # 30 days
compactor: working_directory: /loki/compactor retention_enabled: true retention_delete_delay: 2h delete_request_cancel_period: 24hStorage Tiers
For larger deployments, consider tiered storage:
| Tier | Duration | Storage | Cost |
|---|---|---|---|
| Hot | 0-7 days | Local SSD / Fast disk | $$$ |
| Warm | 7-30 days | Object storage (S3/GCS) | $$ |
| Cold | 30-90 days | Cheap object storage (S3 Glacier) | $ |
| Archive | 90+ days | Only if compliance requires it | Varies |
Loki supports object storage backends natively, making this kind of tiering straightforward.
Estimating Storage Needs
A rough formula:
Daily log volume = (avg log line size) x (lines per second) x 86400
Example:200 bytes x 100 lines/sec x 86400 = ~1.6 GB/day uncompressedWith Loki compression: ~0.3-0.5 GB/day30-day retention: ~10-15 GB totalThat’s very manageable. But scale that to 50 services each doing 1000 lines/sec and suddenly you’re looking at real storage costs. Plan accordingly.
Compression Matters
Always enable compression. Loki compresses by default, but if you’re using json-file logging driver, add "compress": "true" to your log options. Log data is extremely compressible (often 10:1 or better) because of how repetitive it is.
Putting It All Together: A Production-Ready Stack
Here’s what a solid Docker logging architecture looks like for a small to medium team:
Containers (stdout/stderr) | v Docker json-file driver (with rotation) | v Promtail / Fluent Bit (collection + basic filtering) | v Loki (storage + indexing) | v Grafana (visualization + alerting)And here’s the complete Docker Compose that ties it all together:
version: "3.8"
x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "5" tag: "{{.Name}}"
services: # --- Your Application Services --- web: image: nginx:latest ports: - "80:80" logging: *default-logging
api: image: my-api:latest ports: - "8080:8080" logging: *default-logging
# --- Logging Infrastructure --- loki: image: grafana/loki:2.9.0 ports: - "3100:3100" volumes: - ./config/loki.yml:/etc/loki/local-config.yaml - loki-data:/loki command: -config.file=/etc/loki/local-config.yaml logging: *default-logging
promtail: image: grafana/promtail:2.9.0 volumes: - ./config/promtail.yml:/etc/promtail/config.yml - /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/run/docker.sock:/var/run/docker.sock command: -config.file=/etc/promtail/config.yml depends_on: - loki logging: *default-logging
grafana: image: grafana/grafana:latest ports: - "3000:3000" volumes: - grafana-data:/var/lib/grafana environment: - GF_SECURITY_ADMIN_PASSWORD=changeme - GF_INSTALL_PLUGINS=grafana-loki-datasource depends_on: - loki logging: *default-logging
volumes: loki-data: grafana-data:Final Thoughts
Docker logging doesn’t have to be painful. Here’s the TL;DR game plan:
- Start simple: Use
json-filewith rotation configured. Always. - Log to stdout: Make sure your apps write to stdout/stderr, not internal files.
- Set rotation early: Configure
max-sizeandmax-filebefore you deploy anything. Do this on day one, not after your disk is full. - Centralize when ready: When you have more than a handful of containers, set up Loki + Grafana. The initial time investment pays for itself the first time you need to search logs across multiple services.
- Filter aggressively: Don’t ship everything to your central store. Health checks, debug logs, and routine noise don’t need to be there.
- Plan for storage: Set retention policies, enable compression, and estimate your costs before they surprise you.
Logging is one of those things that nobody thinks about until everything is on fire. Do yourself a favor and set it up properly now. Your 2 AM self will be grateful.
Now go forth and centralize those logs. Your containers have been screaming into the void long enough.