When a container misbehaves, its logs and its state are the first places to look. Docker captures everything a container writes to standard output and standard error, and it records why a container stopped, which exit code it returned and whether the kernel killed it for using too much memory. In this tutorial you will read and filter container logs, configure log rotation so logs cannot fill the disk, and work through a repeatable method for diagnosing the most common container failures on Ubuntu 24.04.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
- A non-root user with
sudoprivileges, added to thedockergroup. - Docker Engine installed from Docker's official repository.
How Docker logging works
Docker only collects what the main process of the container (PID 1) writes to stdout and stderr. An application that writes to a file inside the container, such as /var/log/app.log, produces no output in docker logs. Well-built images either log to stdout directly or, like the official Nginx image, symlink their log files to /dev/stdout and /dev/stderr.
The logging driver decides where those streams go. The default is json-file, which stores one JSON line per message under /var/lib/docker/containers/<id>/. Check the driver in use:
docker info --format '{{.LoggingDriver}}'
json-file
Step 1 - Reading container logs
Start a test container that serves a page and logs every request:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:alpine
curl -s -o /dev/null http://127.0.0.1:8080
curl -s -o /dev/null http://127.0.0.1:8080/missing
Show all logs of the container:
docker logs web
The output mixes Nginx's startup messages (on stderr) with the access log lines (on stdout):
/docker-entrypoint.sh: Configuration complete; ready for start up
172.17.0.1 - - [25/Sep/2026:10:02:11 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.5.0" "-"
172.17.0.1 - - [25/Sep/2026:10:02:12 +0000] "GET /missing HTTP/1.1" 404 153 "-" "curl/8.5.0" "-"
The flags you will use most:
| Command | What it shows |
|---|---|
docker logs -f web | Follow new lines as they arrive (Ctrl+C to stop) |
docker logs --tail 100 web | Only the last 100 lines |
docker logs --since 10m web | Lines from the last 10 minutes (also 2h or a timestamp) |
docker logs --since 2026-09-25T09:00:00 --until 2026-09-25T10:00:00 web | A specific time window |
docker logs -t web | Prefix each line with Docker's timestamp |
docker logs writes the container's stdout to your stdout and its stderr to your stderr, so to search both with grep you must merge them:
docker logs web 2>&1 | grep ' 404 '
172.17.0.1 - - [25/Sep/2026:10:02:12 +0000] "GET /missing HTTP/1.1" 404 153 "-" "curl/8.5.0" "-"
For a Compose project, docker compose logs works the same way and prefixes each line with the service name. Run it from the project directory:
docker compose logs -f --tail 50 service_name
Step 2 - Configuring log rotation
With the default configuration, json-file logs grow without limit. A chatty container can fill the disk and take every other service on the server down with it. Set limits globally in the Docker daemon configuration:
sudo nano /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Each container now keeps at most three files of 10 MB. If the file already exists with other settings, merge these keys into it rather than replacing it, and keep it valid JSON. Restart Docker to apply the change:
sudo systemctl restart docker
ImportantLog options apply only to containers created after the change. Existing containers keep their old settings until you remove and recreate them (for Compose projects,
docker compose up -d --force-recreate).
Verify the settings on a newly created container:
docker rm -f web
docker run -d --name web -p 127.0.0.1:8080:80 nginx:alpine
docker inspect --format '{{.HostConfig.LogConfig}}' web
{json-file map[max-file:3 max-size:10m]}
You can also set limits for a single service in compose.yaml, which overrides the daemon defaults:
services:
web:
image: nginx:alpine
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Sending logs to journald instead
If you already collect system logs with journald, the journald driver sends container output there, where it is rotated by journald's own limits. Set "log-driver": "journald" in daemon.json, restart Docker and recreate the containers. docker logs keeps working, and you can also query by container name:
journalctl CONTAINER_NAME=web --since "10 minutes ago"
Step 3 - Checking a container's state and exit code
When a container is not running, docker ps -a shows how it ended:
docker run -d --name broken alpine sh -c 'echo "starting"; exit 3'
docker ps -a --filter name=broken
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a41c9e2f0d11 alpine "sh -c 'echo \"starti…" 5 seconds ago Exited (3) 4 seconds ago broken
For the full picture, query the state fields directly:
docker inspect --format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} restarts={{.RestartCount}} error={{.State.Error}}' broken
status=exited exit=3 oom=false restarts=0 error=
The exit code usually tells you where to look next:
| Exit code | Meaning | Where to look |
|---|---|---|
0 | The main process finished normally | The command ends instead of staying in the foreground |
1, 2, other small numbers | The application reported an error | docker logs |
125 | Docker could not create or start the container | The error printed by docker run |
126 / 127 | Command not executable / not found | CMD or ENTRYPOINT in the image, missing binary |
137 | Killed with SIGKILL, often by the out-of-memory killer | OOMKilled field, memory limits |
139 | Segmentation fault | Application or architecture mismatch |
143 | Stopped with SIGTERM, for example by docker stop | Normally expected |
Logs remain available after the container exits, so docker logs broken shows starting. Remove it with docker rm broken.
Step 4 - Inspecting a running container
Open a shell inside a running container to check files, environment variables or connectivity from its point of view. Minimal images such as Alpine have sh instead of bash:
docker exec -it web sh
A few useful one-off commands without an interactive shell:
docker exec web env
docker exec web cat /etc/nginx/conf.d/default.conf
docker top web
Many production images do not contain a shell or network tools at all. Instead of installing tools into them, attach a debugging container to the target's network namespace. The nicolaka/netshoot image bundles curl, dig, ss, tcpdump and more:
docker run --rm -it --network container:web nicolaka/netshoot
Inside it, localhost is the web container, so curl -I http://localhost and ss -ltn show what that container sees.
To copy a file out of a container, including a stopped one, use docker cp:
docker cp web:/etc/nginx/nginx.conf ./nginx.conf
Watching resource usage and events
docker stats shows live CPU, memory, network and disk I/O per container. Use --no-stream for a single snapshot:
docker stats --no-stream
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
5d2f8a7c1b33 web 0.00% 3.4MiB / 1.915GiB 0.17% 1.2kB / 1.4kB 0B / 4.1kB 3
docker events streams what the daemon does: starts, stops, health status changes, OOM kills. It is the quickest way to catch a container stuck in a restart loop:
docker events --since 30m --filter type=container --filter event=die
Step 5 - Reading the Docker daemon logs
When the problem is Docker itself (containers fail to start with daemon errors, networks cannot be created, the service does not come up), read the daemon's logs from journald:
sudo journalctl -u docker.service --since "1 hour ago" --no-pager
If sudo systemctl restart docker fails, the most common cause is a syntax error in /etc/docker/daemon.json. Validate it:
sudo dockerd --validate --config-file=/etc/docker/daemon.json
configuration OK
For more detail, add "debug": true to daemon.json, restart Docker, reproduce the problem, and remove the setting afterwards because debug logging is very verbose.
Common problems and fixes
The container exits immediately
A container runs only as long as its main process. If docker ps -a shows Exited (0), the command finished: the process daemonized itself (for example a service started with its default background mode) or the image's CMD was overridden with something short-lived. Run the process in the foreground, as the official images do (nginx -g 'daemon off;'). If the exit code is not 0, read docker logs: a missing environment variable or configuration file is the usual cause.
To explore an image whose default command crashes, start it with a shell instead:
docker run --rm -it --entrypoint sh image_name
The port is already in use
Bind for 0.0.0.0:80 failed: port is already allocated
Another container or a host process is using the port. Find out which:
docker ps --filter publish=80
sudo ss -ltnp 'sport = :80'
Stop the conflicting service or publish your container on another host port (-p 8081:80).
The container is killed with exit code 137
Check whether the kernel's out-of-memory killer stopped it:
docker inspect --format '{{.State.OOMKilled}}' container_name
sudo dmesg | grep -i 'killed process'
If OOMKilled is true, the container hit its --memory limit or the host ran out of RAM. Raise the limit, reduce the application's memory usage (worker count, cache sizes, JVM heap), or add memory to the server. A 137 with OOMKilled=false means something else sent SIGKILL, for example docker kill or a stop that exceeded the grace period.
The disk is full
Show how much space images, containers, volumes and the build cache use:
docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 14 5 4.2GB 2.9GB (69%)
Containers 6 5 120MB 8kB (0%)
Local Volumes 4 3 1.1GB 240MB (21%)
Build Cache 52 0 1.8GB 1.8GB
Remove stopped containers, unused networks, dangling images and build cache:
docker system prune
Add -a to also remove every image not used by a container. Volumes are only removed with --volumes, so review them first. If docker system df looks small but /var/lib/docker is still large, find oversized container logs:
sudo du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -5
That means log rotation is missing: apply Step 2 and recreate the affected containers.
Containers cannot resolve DNS names
Test resolution from a container on the same network:
docker run --rm alpine nslookup cubepath.com
If this fails while resolvectl query cubepath.com works on the host, restart Docker so it re-reads the host's resolver configuration. As a last resort, set explicit servers in daemon.json with "dns": ["1.1.1.1", "8.8.8.8"]. For name resolution between containers, remember that it only works on user-defined networks, not on the default bridge.
Conclusion
You can now read and filter container logs, keep them from filling the disk with rotation, and follow a clear path when something breaks: check the state and exit code, read the logs, inspect the container from inside or through a debug container, and fall back to the daemon logs when Docker itself is the problem. As next steps, ship logs to a central system such as Loki or Graylog, add health checks to your images so docker ps reports real application health, and review our Docker networking guide to troubleshoot connectivity between containers.
