docker rm deletes your logsThat 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.
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.
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.
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.
docker logs returns nothingThree 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 |
Yes, from a local cache, since Docker Engine 20.10 |
none |
No |
The cache is Docker’s “dual logging”. It keeps up to 100 MB per container, rotates, and is deleted with the container. Older engines, and daemons configured with cache-disabled, answer that the logging driver does not support reading. Nothing is broken then. 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.
Ask Docker:
docker inspect --format '{{.LogPath}}' my-container
Under the default json-file driver that prints /var/lib/docker/containers/<id>/<id>-json.log. Under most other drivers, and on Docker Desktop, it prints nothing useful. Docker logs location covers each case, reading the file with jq, and saving docker logs to a file without losing stderr.
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.
Check what you are already carrying:
sudo du -h /var/lib/docker/containers/*/*-json.log | sort -rh | head
The fix is max-size and max-file in /etc/docker/daemon.json, and it only applies to containers created afterwards. Limiting Docker log size covers the settings, the containers they skip, and why truncating the live file is a stopgap.
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.
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.
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.
max-size, max-file, and the existing containers they do not touch.du cannot see.httpd image that has no log file at all💌 Get notified on new features and updates