docker rm deletes your logs. That is the whole problem in one sentence.
Docker’s default driver writes each container’s stdout and stderr to a JSON file under /var/lib/docker/containers/, and docker logs reads it back. One container on one host? Fine. After that it stops helping. The logs die with the container. You cannot search across services. Remove the container and the history goes with it.
Fixing this mostly means picking the right logging driver. You do not need a sidecar, a DaemonSet, or a fleet of collectors.
A container’s logging driver decides where its stdout and stderr go. Four matter here:
| Driver | What it does | Good for |
|---|---|---|
json-file |
Writes JSON to local disk (the default) | Local development |
journald |
Writes to the systemd journal | Any systemd host — recommended |
syslog |
Writes to a syslog daemon | Non-systemd hosts |
local |
Docker’s own compact local format | Local only, cannot forward |
Here is the trick. Get logs into the journal or into syslog and you are on a road other people have already paved. Docker-specific tooling stops being necessary.
Take this path on any modern Linux host. Docker writes to the journal. The CL Agent ships the journal upstream. The journalctl guide describes that mechanism.
Edit or create /etc/docker/daemon.json:
{
"log-driver": "journald",
"log-opts": {
"tag": "{{.Name}}"
}
}
Restart Docker:
sudo systemctl restart docker
Existing containers keep their old driver. Only containers created after the restart pick up the change. Recreate them:
docker compose up -d --force-recreate
The tag option makes this usable. {{.Name}} writes the container name into the journal’s SYSLOG_IDENTIFIER field, which separates your API container’s logs from your worker’s. Skip it and every container lands under one identifier. Then you cannot filter by service at all.
Check that it works:
journalctl CONTAINER_NAME=my-api -n 20 -o json
To forward only certain containers:
docker run --log-driver=journald --log-opt tag=my-api my-image
In docker-compose.yml:
services:
api:
image: my-image
logging:
driver: journald
options:
tag: "{{.Name}}"
One nice property: docker logs keeps working under the journald driver, because Docker reads the entries back out of the journal. The syslog driver does not do that, and the surprise catches people out.
No systemd on the host? Use the syslog driver and point it at your local syslog daemon:
services:
api:
image: my-image
logging:
driver: syslog
options:
syslog-address: "unixgram:///dev/log"
tag: "{{.Name}}"
Then forward syslog to your central server. The rsyslog guide has that configuration.
One warning: docker logs returns nothing under this driver, because Docker keeps no local copy. Confirm the logs arrive centrally before you depend on it.
docker logs directlyTo forward one container without touching the daemon config, stream its output to the ingest API:
#!/bin/bash
# /usr/local/bin/ship-container-logs.sh
set -euo pipefail
CONTAINER="$1"
ENDPOINT="https://logs.example.com/api/v1/log/YOUR-SOURCE-TOKEN"
docker logs -f --tail 0 --timestamps "$CONTAINER" 2>&1 \
| while IFS= read -r line; do
curl -sf -X POST "$ENDPOINT" --data-binary "$line" >/dev/null || true
done
Run it as a systemd unit so it restarts when the container does:
# /etc/systemd/system/[email protected]
[Unit]
Description=Ship %i container logs to Central Logging
After=docker.service
Requires=docker.service
[Service]
ExecStart=/usr/local/bin/ship-container-logs.sh %i
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now ship-logs@my-api
This sends one HTTP request per log line. Fine for a quiet container, wasteful for a chatty one. Past a few lines per second, switch to Option A. The agent batches. This does not.
Container logs are only as good as what your application prints. Log JSON to stdout and everything downstream gets easier, because Central Logging extracts the fields for you.
Most logging libraries have a JSON mode:
# Python, structlog
structlog.configure(processors=[structlog.processors.JSONRenderer()])
// Node, pino — JSON is the default
const logger = require('pino')()
logger.info({ user_id: 42, route: '/checkout' }, 'order placed')
// Go, log/slog
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("order placed", "user_id", 42, "route", "/checkout")
The Python logging guide covers application-level logging in more depth.
Now filter by container and dig into JSON fields:
SELECT
"timestamp",
json_extract(msg, '$.SYSLOG_IDENTIFIER') AS container,
json_extract(msg, '$.MESSAGE') AS message
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'my-api'
ORDER BY "timestamp" DESC
LIMIT 200;
Error count per container, worth watching right after a deploy:
SELECT
json_extract(msg, '$.SYSLOG_IDENTIFIER') AS container,
COUNT(*) AS errors
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.MESSAGE') LIKE '%error%'
GROUP BY container
ORDER BY errors DESC;
The line you want is the last one before the container exited. Restart loops hide well, because the container looks “up” every time you check.
Two things help. First, an alert rule on OOM kills, which are otherwise invisible:
oom-kill OR "Out of memory"
Second, host monitoring, so you know the machine itself is healthy.
Keeping the default json-file driver anywhere? Cap the file size. Docker does not rotate by default, and an uncapped container log will fill your disk:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
This is one of the most common ways a Docker host runs out of disk. On the journald driver the journal’s own limits apply instead — see SystemMaxUse in /etc/systemd/journald.conf.
On systemd hosts, use the journald driver with a tag option and let the agent forward everything. That is one config file and no extra processes. Use the syslog driver elsewhere. Save direct streaming for one-off cases.
Central Logging runs as a single binary on a server you already own. See Deploy to get started.
💌 Get notified on new features and updates