journalctl -u nginx prints nothing? The unit name is usually wrong.
That is the most common reason, and it is not the only one. This guide covers
reading one service’s logs with journalctl, the flags worth knowing, the five
things that make the output empty, and what to do when one service on one host
stops being enough.
journalctl -u nginx
-u takes a unit name. The .service suffix is optional, so -u nginx and
-u nginx.service are the same command.
Guessing the name is where most of the wasted time goes. Ask systemd instead:
systemctl list-units --type=service # running services
systemctl list-units --type=service --all # including stopped ones
systemctl list-unit-files --type=service # everything installed
The names surprise people. Cron is cron on Debian and Ubuntu and crond on
RHEL and Fedora. PHP-FPM carries its version: php8.2-fpm.service. Docker is
docker.service and its socket is a separate unit.
A glob saves you from remembering:
journalctl -u 'php*'
Several units at once interleave in time order, which is what you want when a request crossed both of them:
journalctl -u nginx -u php8.2-fpm
journalctl -u nginx -f # new lines as they arrive
journalctl -u nginx -n 100 -f # the last 100, then follow
Ctrl-C stops it.
journalctl -u nginx --since "1 hour ago"
journalctl -u nginx --since today
journalctl -u nginx --since "2026-08-01 09:00" --until "2026-08-01 10:00"
journalctl -u nginx -p err
-p is a ceiling, not an exact match. -p err gives you err, crit, alert and
emerg. The numbers behind the names:
| Level | Name |
|---|---|
| 0 | emerg |
| 1 | alert |
| 2 | crit |
| 3 | err |
| 4 | warning |
| 5 | notice |
| 6 | info |
| 7 | debug |
journalctl -u nginx -g timeout
-g is case-insensitive while your pattern is all lowercase. Add one capital
letter and it becomes case-sensitive.
The unit name is wrong. Check it with systemctl list-units as above. A
name journald has never seen produces no error, just no output.
The service does not log to the journal. Nginx writes its access log to
/var/log/nginx/access.log by default, not to journald. The unit’s journal
then holds only start, stop and crash messages. Point the service at stdout, or
read its file.
You are not allowed to see it. An unprivileged user sees only their own
messages. Use sudo journalctl -u nginx, or add yourself to the
systemd-journal group.
It is a user unit. Services started by systemctl --user need
journalctl --user -u myapp.
The journal was not persistent. If /var/log/journal does not exist,
systemd keeps the journal in /run/log/journal and the whole thing is erased
on reboot. Create the directory, or set Storage=persistent in
/etc/systemd/journald.conf and restart systemd-journald.
journalctl -u cron shows that cron started your job. It does not show what
the job printed. Cron mails that to the user, and on a server with no mail
transfer agent installed it goes nowhere at all.
That is a separate problem with a separate fix — see monitoring cron jobs.
Every flag above is scoped to the machine you are typing on.
Three app servers behind a load balancer means three SSH sessions and three
journalctl -u myapp -f windows, and you still have to work out which one took
the request. -f across all of them at once is not something journalctl does.
Neither is a query over last month, once the journal has been vacuumed.
Point the CL Agent at a Central Logging instance and every
journal entry arrives as the same JSON object journalctl prints. The unit is in
_SYSTEMD_UNIT, so -u nginx becomes a WHERE clause:
SELECT datetime(CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000, 'unixepoch') AS at,
json_extract(msg, '$._HOSTNAME') AS host,
json_extract(msg, '$.MESSAGE') AS message
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$._SYSTEMD_UNIT') = 'nginx.service'
ORDER BY at DESC
LIMIT 100;
That is journalctl -u nginx, over every host running the agent, with the
hostname in a column so you can tell them apart.
The rest of the flags translate the same way:
| journalctl | SQL |
|---|---|
-u nginx |
json_extract(msg, '$._SYSTEMD_UNIT') = 'nginx.service' |
-p err |
CAST(json_extract(msg, '$.PRIORITY') AS INTEGER) <= 3 |
-g timeout |
json_extract(msg, '$.MESSAGE') LIKE '%timeout%' |
--since "1 hour ago" |
CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000 >= CAST(strftime('%s', 'now', '-1 hour') AS INTEGER) |
| one host | json_extract(msg, '$._HOSTNAME') = 'web-01' |
Three details make the difference between these working and not.
PRIORITY arrives as a string, because journalctl’s JSON output quotes every
field. Compare it without the CAST and SQLite compares text to a number,
which is never true.
strftime returns text too. Cast both sides or the comparison silently matches
nothing.
Keep json_valid(msg) in front. json_extract raises an error on the first
line that is not JSON, and it protects only the terms that come after it in the
WHERE clause.
An error-level query across the fleet, put together:
SELECT datetime(CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000, 'unixepoch') AS at,
json_extract(msg, '$._HOSTNAME') AS host,
json_extract(msg, '$._SYSTEMD_UNIT') AS unit,
json_extract(msg, '$.MESSAGE') AS message
FROM logs
WHERE json_valid(msg)
AND CAST(json_extract(msg, '$.PRIORITY') AS INTEGER) <= 3
AND CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000
>= CAST(strftime('%s', 'now', '-1 hour') AS INTEGER)
ORDER BY at DESC;
Save that as an alert rule and the fleet tells you about its own errors.
If you run one server, journalctl -u is the right tool and a log server is
overhead you do not need. The flags on this page cover it.
The case for shipping the logs starts at the second host, and gets stronger with retention: journald vacuums old entries away, and the incident you want to read about is often older than what is left.
--cursor-file.💌 Get notified on new features and updates