How to view Docker container logs

docker rm deletes your logs

That is the whole problem with docker logs in one line. It reads a file that belongs to the container, and removing the container removes the file.

Everything below is about getting the most out of the command, and then about not depending on it.

The command

docker logs my-container

Name or ID both work. docker ps lists the running ones, docker ps -a includes the stopped ones — and a stopped container still has its logs, which is how you find out why it stopped.

The flags that matter:

docker logs -f my-container              # follow, like tail -f
docker logs --tail 100 my-container      # last 100 lines
docker logs -t my-container              # prefix each line with a timestamp
docker logs --since 30m my-container     # last 30 minutes
docker logs --since 2026-08-30T11:00:00 my-container
docker logs --until 1h my-container      # stop an hour ago

Combine them. This is the one to remember:

docker logs -f --tail 100 -t my-container

You get the recent past for context and then the live stream, with times attached. Plain docker logs -f on a container that has been up for a month replays the month first.

Piping and grepping

docker logs writes container stdout to your stdout and container stderr to your stderr. Most applications log to stderr. So this looks broken:

docker logs my-container | grep error     # misses stderr

Redirect stderr into the pipe:

docker logs my-container 2>&1 | grep error

That trips people up constantly. When docker logs prints plenty and your grep finds nothing, this is why.

Compose

docker compose logs -f api
docker compose logs -f --tail 50          # every service at once
docker compose logs --since 10m web db

With no service named, Compose interleaves all of them and colour-codes the prefixes. That is genuinely useful for watching a request cross services.

When docker logs returns nothing

Three causes, in the order you should check them.

The application does not log to stdout. Docker captures the stdout and stderr of PID 1 in the container. Nothing else. An app configured to write /var/log/app.log inside the container writes it to the container filesystem and Docker never sees it. Point the application at stdout — most frameworks have a console log target — or the container’s logs will stay empty forever.

The logging driver cannot read back. Check it:

docker inspect -f '{{.HostConfig.LogConfig.Type}}' my-container
Driver docker logs works?
json-file (default) Yes
local Yes
journald Yes — Docker reads back out of the journal
syslog, gelf, fluentd, awslogs No

Under those last drivers the daemon answers that the configured logging driver does not support reading. Nothing is broken. The logs went somewhere else and you have to look there.

The container was recreated. docker compose up -d after a config change builds a new container. The old one’s logs went with it.

Where the file lives

Under the default json-file driver:

sudo ls -la /var/lib/docker/containers/$(docker inspect -f '{{.Id}}' my-container)/

The log is <container-id>-json.log. One JSON object per line, each with the text, the stream it came from, and a timestamp.

You rarely need to read it directly. You do need to know it is there, because of the next section.

It will fill your disk

The json-file driver does not rotate anything by default. A chatty container writes until the partition is full, and a full /var/lib/docker takes down every container on the host, not just the noisy one.

Cap it globally in /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
sudo systemctl restart docker

This applies to containers created after the restart. Existing containers keep their old settings until you recreate them.

Check what you are already carrying:

sudo du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | head

The limits you keep hitting

docker logs is a good debugger and a bad system of record.

It shows one container. Chasing a request through a proxy, an API and a worker means three terminals and mental timestamp arithmetic.

It has no search. --since narrows by time and that is the whole query language. Everything else is grep.

It is local. To read the logs you need SSH on the host.

And it is temporary. Redeploy and the history is gone — which is exactly when you most want it, because “it worked before the deploy” is a claim you can only check against the logs from before the deploy.

Keep the logs after the container is gone

Point Docker’s logging driver at the journal, then ship the journal.

{
  "log-driver": "journald"
}
sudo systemctl restart docker

journald is the driver worth choosing here, because docker logs keeps working under it. You lose nothing and gain a copy outside the container.

Then install the CL Agent:

URL = "https://logs.example.com/api/v1/ingest_logs/YOUR-SOURCE-TOKEN"

The agent reads the journal and posts it to your server. It writes its cursor only after the server accepts a batch, so a restart or a network blip does not lose the lines written meanwhile.

Other drivers and a direct docker logs stream are covered in sending Docker container logs to a central server.

Then query across containers

The journald driver tags each entry with the container it came from, so the fields survive the trip:

SELECT json_extract(msg, '$.CONTAINER_NAME') AS container,
       json_extract(msg, '$.MESSAGE')        AS line,
       timestamp
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.CONTAINER_NAME') = 'api'
ORDER BY timestamp DESC
LIMIT 100;

Keep json_valid(msg) first. One plain-text line in the source makes json_extract raise malformed JSON and give up on the query. SQLite short-circuits AND inside a WHERE, so the guard protects the terms after it — but not the SELECT list, which runs against every row the WHERE lets through.

If your application logs JSON, its object arrives as a string inside MESSAGE. Reaching a field takes two hops:

SELECT json_extract(json_extract(msg, '$.MESSAGE'), '$.status') AS status,
       json_extract(json_extract(msg, '$.MESSAGE'), '$.path')   AS path,
       timestamp
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.CONTAINER_NAME') = 'api'
  AND json_valid(json_extract(msg, '$.MESSAGE'))
  AND CAST(json_extract(json_extract(msg, '$.MESSAGE'), '$.status') AS INTEGER) >= 500
ORDER BY timestamp DESC;

Two details in there earn their place.

A single-hop json_extract(msg, '$.status') returns nothing rather than failing. No error, no rows, no clue. If a query over container JSON comes back empty, count your hops first.

And the CAST is not decoration. Without it SQLite compares a TEXT value against an integer, and every TEXT sorts above every integer — so '200' >= 500 is true. An uncast threshold quietly matches every request you have.

Where to go next

💌 Get notified on new features and updates

Only sent when a new version is released. Nothing else.