Upstream Apache ships ErrorLog "logs/error_log". That path is relative to ServerRoot, and every distribution sets ServerRoot somewhere different — or replaces the directive outright.
Three real answers on three systems:
| System | Path |
|---|---|
| Debian, Ubuntu | /var/log/apache2/error.log |
| RHEL, Rocky, Alma, Fedora | /var/log/httpd/error_log |
| Built from source | <ServerRoot>/logs/error_log |
Note the filename changes too: error.log on Debian, error_log on Red Hat. A find for one will not turn up the other.
On Red Hat, ServerRoot is /etc/httpd and the package makes /etc/httpd/logs a symlink to /var/log/httpd. Both paths are correct. Only one is where the file lives.
Stop guessing and ask Apache.
apachectl -S
That prints the answer directly:
ServerRoot: "/etc/httpd"
Main DocumentRoot: "/var/www/html"
Main ErrorLog: "/var/log/httpd/error_log"
Main ErrorLog is already resolved against ServerRoot, so it is the real path, not the logs/error_log from the config file.
Above those lines, apachectl -S lists every virtual host with the config file and line number that defined it:
port 443 namevhost example.com (/etc/httpd/conf.d/example.conf:12)
That tells you which vhost serves a request and which file to edit. Run it before you conclude anything.
Main ErrorLog is the main server’s log. A vhost with its own ErrorLog writes somewhere else, and the dump does not show that. Grep for the directives to see all of them:
# Debian, Ubuntu
grep -r 'ErrorLog\|CustomLog' /etc/apache2/
# RHEL and derivatives
grep -r 'ErrorLog\|CustomLog' /etc/httpd/
On Debian you will see ${APACHE_LOG_DIR}. That is not a shell variable Apache invents — it comes from /etc/apache2/envvars, where the package sets APACHE_LOG_DIR=/var/log/apache2.
And confirm the config you are reading is the config that is loaded:
apachectl configtest
Apache’s own config comment says it plainly, and people still miss it.
Define ErrorLog inside a <VirtualHost> and that host’s errors go there and not to the main error log. Same for CustomLog and the access log. Debian’s shipped default vhost does exactly this:
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
Copy that vhost file to make a second site, change the ServerName and forget the log paths, and both sites now write to the same two files. Change the log paths and forget which you changed, and you have been tailing an empty file.
apachectl -S names the file that defines each vhost. Open that file and read its ErrorLog. Do that before you conclude the error log is broken.
The default format is assembled in Apache’s own code, not configured anywhere. A line looks like this:
[Sun Sep 20 10:04:22.123456 2026] [proxy_fcgi:error] [pid 2345:tid 6789] [client 192.0.2.4:51000] AH01067: Failed to read FastCGI header, referer: https://example.com/
| Piece | Meaning |
|---|---|
[Sun Sep 20 10:04:22.123456 2026] |
Local time, microsecond resolution |
[proxy_fcgi:error] |
Which module, and at what severity |
[pid 2345:tid 6789] |
Process and thread |
[client 192.0.2.4:51000] |
Who asked, and from which port |
AH01067 |
A stable message id |
Failed to read FastCGI header |
The message |
, referer: ... |
Appended when the request had a Referer |
Two of those are worth knowing about.
[module:level] tells you who is complaining. [ssl:warn] is TLS. [proxy_fcgi:error] is PHP-FPM. [core:error] is Apache itself. When a module you do not recognise is flooding the log, that is the module to turn down.
AHnnnnn is a stable id. Apache builds these with a macro that stamps a fixed five-digit number onto the message. The prose around it changes between versions; the id does not. Search for the id, not the sentence. It is also the right thing to paste into a search engine.
At debug and the trace levels, Apache prefixes the message with the C source file and line that emitted it. Useful once, confusing the first time.
LogLevel warn is the default on every distribution and on a stock build.
Apache’s levels, with the numbers it compares internally:
| Level | Number |
|---|---|
emerg |
0 |
alert |
1 |
crit |
2 |
error |
3 |
warn |
4 |
notice |
5 |
info |
6 |
debug |
7 |
trace1 … trace8 |
8 … 15 |
A message is dropped when its number is greater than the configured level. So LogLevel warn keeps warnings and everything more severe, and throws away info, debug and all eight trace levels.
One exception, and it is in the code rather than the docs: a message at exactly notice is never suppressed, whatever LogLevel says. That is why startup and shutdown lines appear in an error log set to crit.
Raise the level for one module instead of the whole server:
LogLevel warn ssl:info proxy_fcgi:debug
That syntax is Apache 2.4 and later. It is the difference between a readable log and 400 MB of TLS handshake detail.
Reload to apply:
sudo apachectl configtest && sudo systemctl reload apache2 # Debian, Ubuntu
sudo apachectl configtest && sudo systemctl reload httpd # RHEL and derivatives
Turn the module back down when you are done. An info error log on a busy server is large, and trace8 will fill a disk.
While you are here: combined does not mean the same thing everywhere. Three shipped configs, three definitions.
Upstream httpd:
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
CustomLog "logs/access_log" common
Note the active line uses common, not combined. A stock build from source logs no referer and no user agent until you change that.
Debian and Ubuntu:
LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
%O instead of %b. Different number: %b is response body bytes, %O is everything Apache wrote to the socket including headers. Two servers, same format name, figures that do not add up.
Fedora and RHEL:
CustomLog "logs/access_log" combined_ext
combined_ext is a Red Hat nickname. It appends PID: %P %{tid}P %T — process, thread, and the request duration in seconds. Handy, and it breaks every parser written for the standard format.
Check yours before you write anything that reads the file:
grep -r 'LogFormat\|CustomLog' /etc/apache2/ /etc/httpd/ 2>/dev/null
Two more details in those format strings.
%b prints -, not 0, when no body was sent. That is the Common Log Format convention. %B prints 0. A sum over %b in awk silently skips every 304.
Use %>s, not %s. Bare %s reports the status of the original request; %>s reports the final one. With an ErrorDocument or any internal redirect those differ, and %s tells you about a request the client never saw. Every shipped config uses %>s for this reason.
CustomLog ... env=VAR — the line is written only when that environment variable is set. This is how health checks get excluded, and how the request you want gets excluded with them.
CustomLog ... expr=... — the same thing with an expression. If the expression errors, Apache logs a warning to the error log and drops the line.
BufferedLogs On — Apache holds lines in memory and writes them in batches. tail -f looks stalled and is not.
If Apache sits behind nginx, HAProxy or a cloud load balancer, %h is the proxy on every single line. The client is in X-Forwarded-For.
Log it directly:
LogFormat "%{X-Forwarded-For}i %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" proxied
Or enable mod_remoteip, set RemoteIPHeader X-Forwarded-For and a RemoteIPTrustedProxy for each proxy you actually run, and %h becomes the client again.
Set the trusted list. The module’s own source comment is blunt about what happens otherwise: with no RemoteIPTrustedProxy or RemoteIPInternalProxy configured, “all proxies will be considered as external trusted proxies”. So RemoteIPHeader on its own means anyone can send an X-Forwarded-For header and write whatever address they like into your access log.
Nginx’s realip module defaults the other way — with no set_real_ip_from it does nothing. If you run both, do not assume the safe default carries over. Reading Nginx access logs has that side.
Startup failures. If Apache dies before it opens the error log, the message goes to stderr and lands in the journal:
journalctl -u apache2 --since "10 minutes ago" # Debian, Ubuntu
journalctl -u httpd --since "10 minutes ago" # RHEL and derivatives
Check this whenever systemctl start fails and the error log has nothing new. Viewing one service’s logs with journalctl -u covers reading that.
Containers. The official httpd image rewrites the shipped config at build time. Every ErrorLog becomes /proc/self/fd/2 and every CustomLog becomes /proc/self/fd/1, so there is no log file at all — the lines go to Docker:
docker logs --tail 100 my-apache
Viewing Docker container logs covers the rest.
A customer reports a 500. It hit one of your web servers, and you do not know which, so you open four terminals and grep four files. Last month’s errors are in a rotated, gzipped file on a host you have since rebuilt.
Copy the lines somewhere central and you ask the question once.
Apache writes to a file. Give the file to the journal and let the CL Agent take it from there. A systemd unit is the least fragile way:
# /etc/systemd/system/apache-errors.service
[Unit]
Description=Ship the Apache error log to the journal
After=apache2.service
[Service]
ExecStart=/usr/bin/tail -F -n 0 /var/log/apache2/error.log
SyslogIdentifier=apache-error
Restart=always
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now apache-errors
tail -F follows the path rather than the file handle, so logrotate does not quietly end the stream. -n 0 starts at the end instead of replaying the whole file.
On Red Hat, change the path to /var/log/httpd/error_log and After= to httpd.service.
Then install the agent and point it at your server:
# /etc/clagent.toml
URL = "https://logs.example.com/api/v1/ingest_logs/YOUR-SOURCE-TOKEN"
The agent runs journalctl -o json, so each entry arrives as an object and the Apache line sits in MESSAGE:
SELECT timestamp,
json_extract(msg, '$._HOSTNAME') AS host,
json_extract(msg, '$.MESSAGE') AS line
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'apache-error'
ORDER BY timestamp DESC
LIMIT 100;
json_valid(msg) goes first and stays first. One plain-text line anywhere in the source makes json_extract raise malformed JSON, and SQLite short-circuits AND inside a WHERE clause, so the guard protects the terms after it. It does not protect the SELECT list — the guard has to remove the bad rows, not merely precede the extracts.
Which module is complaining, across the whole fleet:
SELECT substr(json_extract(msg, '$.MESSAGE'),
instr(json_extract(msg, '$.MESSAGE'), '] [') + 3,
instr(substr(json_extract(msg, '$.MESSAGE'),
instr(json_extract(msg, '$.MESSAGE'), '] [') + 3), ']') - 1) AS tag,
COUNT(*) AS n
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'apache-error'
GROUP BY tag
ORDER BY n DESC;
tag n
---------------- -
proxy_fcgi:error 6
Count by message id, which groups errors that have the same cause and different wording:
SELECT substr(json_extract(msg, '$.MESSAGE'),
instr(json_extract(msg, '$.MESSAGE'), 'AH'), 7) AS code,
COUNT(*) AS n
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'apache-error'
AND instr(json_extract(msg, '$.MESSAGE'), 'AH') > 0
GROUP BY code
ORDER BY n DESC;
Keep the instr(...) > 0 guard. Not every line carries an id — third-party modules often skip the macro. When instr finds nothing it returns 0, and substr(line, 0, 7) does not error. It returns the first six characters of the line, so an untagged message shows up in your results as a code called [Sun S. Measured; it is the quietest way to get a wrong answer on this page.
Errors by host, last day:
SELECT json_extract(msg, '$._HOSTNAME') AS host, COUNT(*) AS errors
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'apache-error'
AND timestamp > CAST(strftime('%s', 'now', '-1 day') AS INTEGER)
GROUP BY host
ORDER BY errors DESC;
One host with all the errors is a sick host. Errors spread evenly is a sick backend.
An alert rule fires when its query returns any row. The engine checks whether the result is empty and reads nothing further, so SELECT 1 ... LIMIT 1 is the right shape.
Fire when FastCGI failures pass 10 in ten minutes:
SELECT 1 WHERE (
SELECT COUNT(*) FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'apache-error'
AND json_extract(msg, '$.MESSAGE') LIKE '%proxy_fcgi:error%'
AND timestamp > CAST(strftime('%s', 'now', '-10 minutes') AS INTEGER)
) >= 10 LIMIT 1;
Use a scalar subquery for the threshold. SELECT 1 FROM logs WHERE ... HAVING COUNT(*) >= 10 raises HAVING clause on a non-aggregate query, and a rule that errors does not warn you. It never fires.
Rules run every five minutes and deliver within a minute. Set a max frequency on the rule, or one bad afternoon pages you every five minutes for three hours. More in alerting on errors in your logs.
Both distributions ship a logrotate config: /etc/logrotate.d/apache2 or /etc/logrotate.d/httpd. A build from source ships nothing, and the error log grows until the disk does not.
Apache holds the renamed file open after a rotation, so the rotation has to tell it to reopen. The packaged configs do this with a postrotate that reloads Apache. If you write your own, keep that step. Without it Apache keeps writing to the rotated file and the new error.log stays at zero bytes — which is the other way this page’s opening symptom happens.
rotatelogs is the alternative, and it is the one to use when Apache is not under systemd:
ErrorLog "|/usr/bin/rotatelogs -l /var/log/apache2/error.%Y-%m-%d.log 86400"
Apache pipes to it and it handles the rollover itself. No reload, no held file handle.
du cannot see.Central Logging is a single binary you install on a server you already own. See Deploy to get an instance running in about five minutes.
💌 Get notified on new features and updates