Did you know that your docker logs (or docker compose logs) command is powered by a json-file driver by default? You can change this default very easily to handle logs from all future containers the way you want.
Docker has several logging drivers built-in. The simplest upgrade would be the local driver which is a lot like json-file but uses a more efficient file format. A more powerful change is to send logs to a featureful logging service using awslogs, gcplogs, or in the case of the self-hosted example below, using the fluentd logging driver to send logs to self-hosted VictoriaLogs.
A toy app that logs on repeat
First let's make a container that logs JSON to stdout.
# main.py
import logging
import sys
import time
from pythonjsonlogger.json import JsonFormatter
logger = logging.getLogger(__name__)
def main() -> None:
iteration = 0
while True:
logger.info({"message": "Looping", "iteration": iteration})
iteration += 1
time.sleep(1)
if __name__ == "__main__":
logHandler = logging.StreamHandler(stream=sys.stdout)
formatter = JsonFormatter()
logHandler.setFormatter(formatter)
logging.basicConfig(level=logging.INFO, handlers=[logHandler])
main()
# Dockerfile
FROM python:3.12.7-slim-bookworm AS logger
RUN python -m venv /venv
RUN /venv/bin/pip install python-json-logger
COPY main.py /
CMD ["/venv/bin/python", "/main.py"]
# compose.yml
name: docker-logging
services:
logger:
build:
context: .
target: logger
$ docker compose up --build
logger-1 | {"message": "Looping", "iteration": 0}
logger-1 | {"message": "Looping", "iteration": 1}
logger-1 | {"message": "Looping", "iteration": 2}
logger-1 | {"message": "Looping", "iteration": 3}
logger-1 | {"message": "Looping", "iteration": 4}
logger-1 | {"message": "Looping", "iteration": 5}
logger-1 | {"message": "Looping", "iteration": 6}
logger-1 | {"message": "Looping", "iteration": 7}
# etc.
Investigating the json-file logging driver
Explicitly set the json-file logging driver, even though it's the default. We'll start playing with that value soon.
# compose.yml
name: docker-logging
services:
logger:
build:
context: .
target: logger
logging:
driver: 'json-file'
$ docker compose up --build
logger-1 | {"message": "Looping", "iteration": 0}
logger-1 | {"message": "Looping", "iteration": 1}
logger-1 | {"message": "Looping", "iteration": 2}
logger-1 | {"message": "Looping", "iteration": 3}
# etc.
$ docker inspect docker-logging-logger-1 -f "{{println .LogPath}}{{println .H
ostConfig.LogConfig}}"
/var/lib/docker/containers/38aa0a90c0b9bad290fe77e094f6be7af5a39b828e6aad59c03d1511277e64cd/38aa0a90c0b9bad290fe77e094f6be7af5a39b828e6aad59c03d1511277e64cd-json.log
{json-file map[max-file:3 max-size:10m]}
We can see two things: the json-file has a local file where it keeps logs, and some default log rotation values are applied. Just for fun, let's see that file:
$ sudo tail -f `docker inspect docker-logging-logger-1 -f "{{.LogPath}}"`
{"log":"{\"message\": \"Looping\", \"iteration\": 94}\n","stream":"stdout","time":"2025-10-24T17:11:25.253175337Z"}
{"log":"{\"message\": \"Looping\", \"iteration\": 95}\n","stream":"stdout","time":"2025-10-24T17:11:26.2534445Z"}
{"log":"{\"message\": \"Looping\", \"iteration\": 96}\n","stream":"stdout","time":"2025-10-24T17:11:27.254142417Z"}
{"log":"{\"message\": \"Looping\", \"iteration\": 97}\n","stream":"stdout","time":"2025-10-24T17:11:28.254968847Z"}
Using the fluentd logging driver
Use the built-in fluentd driver now. We're not expecting anything great to happen because we're not running fluentbit yet, but there's something to be learned here first.
# compose.yml
name: docker-logging
services:
logger:
build:
context: .
target: logger
logging:
driver: 'fluentd'
$ docker compose up --build
[+] Running 1/1
✔ Container docker-logging-logger-1 Recreated 0.2s
Attaching to logger-1
Error response from daemon: failed to create task for container: failed to initialize logging driver: dial tcp 127.0.0.1:24224: connect: connection refused
The container won't even start up because the logging driver couldn't connect to fluentbit! This means that you should never use this logging driver without setting the async setting:
fluentd-async: Docker connects to Fluentd in the background. Messages are buffered until the connection is established. Defaults to false.
Let's make that change and get the container running.
# compose.yml
name: docker-logging
services:
logger:
build:
context: .
target: logger
logging:
driver: 'fluentd'
options:
fluentd-async: 'true'
$ docker compose up --build
[+] Running 1/1
✔ Container docker-logging-logger-1 Recreated 0.2s
Attaching to logger-1
logger-1 | {"message": "Looping", "iteration": 0}
logger-1 | {"message": "Looping", "iteration": 1}
logger-1 | {"message": "Looping", "iteration": 2}
logger-1 | {"message": "Looping", "iteration": 3}
logger-1 | {"message": "Looping", "iteration": 4}
Now our container has no LogPath and we see it's using the fluentd driver:
$ docker inspect docker-logging-logger-1 -f "{{println .LogPath}}{{println .HostConfig.LogConfig}}"
{fluentd map[fluentd-async:true]}
I have to guess that the fluentd logging driver is just returning logs from memory, or else docker compose logs -f wouldn't work.
Sending logs to fluentbit
We'll start with fluentbit receving logs from the logger application and writing them to its own stdout.
# Dockerfile, at the bottom
FROM fluent/fluent-bit:4.1.1 AS fluentbit
COPY fluentbit.yml /fluent-bit/etc/fluent-bit.yml
RUN ["/fluent-bit/bin/fluent-bit", "--dry-run", "-c", "/fluent-bit/etc/fluent-bit.yml"]
CMD ["/fluent-bit/bin/fluent-bit", "-c", "/fluent-bit/etc/fluent-bit.yml"]
# fluentbit.yml
pipeline:
inputs:
- name: forward
outputs:
- name: stdout
match: '*'
format: json
# compose.yml
name: docker-logging
services:
logger:
# ...
fluentbit:
build:
context: .
target: fluentbit
ports:
- '127.0.0.1:24224:24224'
The logging driver writes the Fluent "forward" protocol to http://127.0.0.1:24224 by default, so all we have to do is map that port to the fluentbit container.
$ docker compose up --build
# ... build output ...
logger-1 | {"message": "Looping", "iteration": 0}
fluentbit-1 | [{"date":1761327379.0,"container_name":"/docker-logging-logger-1","source":"stdout","log":"{\"message\": \"Looping\", \"iteration\": 0}","container_id":"5c9bd5e7ee73876b9f57b1dec4a27848ed34b047d16aa81cc2fa7ec57be6a273"}]
logger-1 | {"message": "Looping", "iteration": 1}
fluentbit-1 | [{"date":1761327380.0,"log":"{\"message\": \"Looping\", \"iteration\": 1}","container_id":"5c9bd5e7ee73876b9f57b1dec4a27848ed34b047d16aa81cc2fa7ec57be6a273","container_name":"/docker-logging-logger-1","source":"stdout"}]
# etc.
Here we see the output from logger itself, then we see it arrives at fluentbit and is logged to its own stdout interpreting the JSON log line as an unstructured string. We also see that we get container_id and container_name labels on the log messages for free from the Docker logging driver. We can have fluentbit parse the "log" key as JSON and merge those values with the rest of the message.
# fluentbit.yml
parsers:
- name: docker
format: json
pipeline:
inputs:
- name: forward
filters:
- name: parser
match: '*'
key_name: log
parser: docker
reserve_data: on
outputs:
- name: stdout
match: '*'
format: json
$ docker compose up --build
logger-1 | {"message": "Looping", "iteration": 0}
fluentbit-1 | [{"date":1761328365.0,"message":"Looping","iteration":0,"source":"stdout","container_id":"5c9bd5e7ee73876b9f57b1dec4a27848ed34b047d16aa81cc2fa7ec57be6a273","container_name":"/docker-logging-logger-1"}]
logger-1 | {"message": "Looping", "iteration": 1}
fluentbit-1 | [{"date":1761328366.0,"message":"Looping","iteration":1,"container_id":"5c9bd5e7ee73876b9f57b1dec4a27848ed34b047d16aa81cc2fa7ec57be6a273","container_name":"/docker-logging-logger-1","source":"stdout"}]
Excellent, the logs are getting to fluentbit and the structure is being interpreted correctly, including metadata added by Docker.
Changing the default Docker logging driver
Docker has a daemon-wide configuration file we can use to configure the default logging driver for all future containers. I'll move the configuration out of compose.yml and put it in daemon.json:
# compose.yml
name: docker-logging
services:
logger:
build:
context: .
target: logger
fluentbit:
build:
context: .
target: fluentbit
logging:
driver: local
ports:
- '127.0.0.1:24224:24224'
// /etc/docker/daemon.json
{
"log-driver": "fluentd",
"log-opts": {
"fluentd-async": "true",
"tag": "{{.Name}}"
}
}
Then, restart the Docker daemon. That probably means a systemctl restart docker for you. Notice that fluentbit itself does not get the default logging driver value, otherwise its logs will loop back to itself recursively. The "tag" log option will send the full container name as the log tag to Fluentbit, which is much more useful for routing logs than the default of sending the container ID.
Sending logs to VictoriaLogs
Run VictoriaLogs based on their Docker quickstart and configure fluentbit to send logs to is based on their fluentbit ingestion docs.
# compose.yml
name: docker-logging
services:
logger:
build:
context: .
target: logger
fluentbit:
build:
context: .
target: fluentbit
logging:
driver: local
ports:
- '127.0.0.1:24224:24224'
networks:
- logging
victorialogs:
image: docker.io/victoriametrics/victoria-logs:v1.36.1
ports:
- '127.0.0.1:9428:9428'
volumes:
- victoria-logs-data:/victoria-logs-data
command: ['-storageDataPath=/victoria-logs-data']
networks:
- logging
volumes:
victoria-logs-data:
networks:
logging:
# fluentbit.yml
service:
# Without this, fluentbit logs every time it writes to VictoriaLogs
log_level: warn
parsers:
- name: docker
format: json
pipeline:
inputs:
- name: forward
filters:
- name: parser
match: '*'
key_name: log
parser: docker
reserve_data: on
outputs:
- name: http
match: '*'
host: 'victorialogs'
port: 9428
uri: '/insert/jsonline?_stream_fields=container_name,container_id&_msg_field=message&_time_field=date'
format: json_lines
json_date_format: iso8601
Now you can visit VictoriaLogs' UI at http://127.0.0.1:9428 and query structured logs, grouped by container name and ID.

Further possibilities
Some ideas to expand upon this:
- Ingest Docker events as logs so you can see everything happening with your system's Docker daemon
- Visualize logs in Grafana