Log aggregation
All container logs can be collected into Loki and browsed in
Grafana, which is a lot more comfortable than docker compose logs
when several containers are involved or when you want to look at a request that happened an hour ago.
docker compose up -d grafana
This starts three containers:
| Container | Purpose |
|---|---|
alloy |
Grafana Alloy collects the logs of all containers of this compose project through the docker socket and pushes them to Loki. It replaces Promtail, which reached end of life in March 2026. |
loki |
Stores the logs on a local volume |
grafana |
Web interface to search and visualize them |
Afterwards open http://grafana.local and log in with admin / admin,
which can be changed with GRAFANA_USER and GRAFANA_PASSWORD in your .env file.
What is collected
Alloy reads the docker logs of every container of this compose project, so nothing has to be
mounted or configured per service. Containers of other projects running on the same docker host are
not touched, the scoping is done with the com.docker.compose.project label and therefore relies on
COMPOSE_PROJECT_NAME being set in your .env file, which bootstrap.sh does for you.
The Nextcloud containers already write data/nextcloud.log, the cron log and the xdebug log to
their stdout and Apache logs there as well, which means the full Nextcloud log ends up in Loki
without any further setup. One container log therefore mixes several formats, the log_type
label separates them again:
log_type |
Content |
|---|---|
nextcloud |
nextcloud.log, parsed as JSON |
access |
The access log of the nginx proxy, which sees every request of the whole setup |
apache_access |
The access log of Apache itself, which also covers internal requests that never pass the proxy |
apache_error |
Apache error log, this is where PHP fatals and warnings show up that never reach nextcloud.log |
xdebug |
Xdebug output |
Everything without one of those formats, for example the databases, the signaling server or the
cron output, has no log_type and is stored as it is.
Labels
Every log entry carries container, service and job="docker". Loki queries start with a
stream selector, so only fields with few, known values are labels, everything with many possible
values is structured metadata
instead, which is filtered after a | and does not create a stream per value.
Lines from nextcloud.log are parsed as JSON and get:
- Labels:
level,app - Structured metadata:
reqId,user,method,url,scriptName,remoteAddr,userAgent,versionandexception, the class name of a logged exception
The proxy is configured with a LOG_FORMAT that writes logfmt instead of the combined format, so
its access lines carry vhost as a label and method, path, query, route, status,
bytes, duration, upstream_time, remote_addr, user_agent and reqId as structured
metadata. Status codes are deliberately not labels, a label per status code would multiply the
streams of every container.
Grouping by path is pointless in Nextcloud because it contains conversation tokens, user names,
file paths and ids, so almost every request has its own. route is the same path with those parts
replaced, which is what the dashboard groups by:
| Path | Route |
|---|---|
/ocs/v2.php/apps/spreed/api/v1/chat/jetukpyo |
/ocs/v2.php/apps/spreed/api/v1/chat/{token} |
/remote.php/dav/files/admin/Photos/image.jpg |
/remote.php/dav/files/{path} |
/index.php/avatar/alice/64 |
/index.php/avatar/{user}/{id} |
/apps/files/js/merged-index.js |
{static}.js |
The rules live at the end of the proxy block in config.alloy and are easy to extend, each one
replaces only its capture group and keeps the rest of the path.
duration is the request time in seconds, which is what the response time panels are built on,
and reqId is the request id Nextcloud returns in its X-Request-Id response header. It ties an
access line to the nextcloud.log entries of the same request, so a slow or failing request can
be opened in the application log with one click. Responses that Nextcloud does not render through
its app framework, for example WebDAV through remote.php or static files, have no such header
and therefore no reqId.
The level label uses the same five values everywhere, debug, info, warn, error and
fatal, so a single query covers the whole setup. Besides Nextcloud it is set for:
talk-janus, which prefixes everything but info with[FATAL],[ERR]or[WARN]talk-recording, which logs in Pythons defaultLEVEL:logger:messageformat- the Apache error log, whose
[php:error]style tags are mapped to the same names
The signaling server writes plain Go log lines without a level and there is nothing else worth
labelling in them, the same is true for the cron output and the databases. For those Loki still
adds its own guess as the detected_level structured metadata field.
Dashboards
Three dashboards are provisioned, the dropdown in the top left switches between them and keeps the selected time range:
| Dashboard | Use it for |
|---|---|
| Nextcloud logs | The application log. Errors and exceptions at the top, volume by level, every exception class over time so that a new one stands out, the most frequent exceptions, where errors come from and the log itself. Clicking an exception or app in a table filters the log to it. |
| Access log | HTTP traffic as seen by the proxy. Share of 5xx and 4xx responses and the p95 response time at the top, requests by status class over time, p50/p95/p99 response time, sortable tables of the failing, slowest and busiest routes, and every single request slower than a configurable threshold, whose request id opens the matching Nextcloud log entries. |
| Container logs | Everything else, the databases, Redis, the Talk backends, the Apache error log and the tooling containers, by service and level. |
The tiles at the top only turn amber or red when something needs a look. Filters named Log: … only
apply to the log list at the bottom, so the overview above never changes unnoticed, while the instance
or host filter and Search apply to every panel.
Response times leave out the requests Talk keeps open on purpose, long polling of the chat and the
internal signaling as well as websockets, otherwise they would dominate every latency panel. For the
same reason the 4xx share ignores 499, which nginx logs when a client closes such a request.
Useful queries
Errors anywhere in the setup, Nextcloud instances and Talk backends alike:
{level=~"error|fatal"}
Only the application log of the Nextcloud instances, without access lines and cron output:
{log_type="nextcloud"}
Requests that failed with a server error:
{log_type="access"} | status=~"5.."
Requests that took longer than two seconds:
{log_type="access"} | duration > 2
The 95th percentile of the response time per host:
quantile_over_time(0.95, {log_type="access"} | unwrap duration [5m]) by (vhost)
The busiest routes of the last hour:
topk(10, sum by (route) (count_over_time({log_type="access"}[1h])))
The most frequent exceptions of the last hour:
topk(10, sum by (exception) (count_over_time({log_type="nextcloud"} | exception != "" [1h])))
Everything a single container logged:
{container="master-nextcloud-1"}
All log entries belonging to one request, the request id can also be copied from the
X-Request-Id response header:
{service=~".+"} | reqId = `bJSJXYCTU0gBjVZq5Vtm`
Log entries of one Nextcloud app only:
{app="spreed"}
Everything the Talk backends logged:
{service=~"talk-.+"}
Number of errors per app over the last hour:
sum by (app) (count_over_time({level=~"error|fatal"}[1h]))
In the log view of Grafana every entry has a reqId field. Clicking it opens all log entries of
that request, which is the fastest way to find out what else happened while a request failed.
Configuration
- Collection pipeline:
docker/configs/alloy/config.alloy - Loki:
docker/configs/loki/config.yml - Grafana datasource and dashboards:
docker/configs/grafana/
Logs are kept for 7 days, this can be changed with LOKI_RETENTION_PERIOD in your .env file.
Grafana asks for a login instead of allowing anonymous access, because an anonymous session cannot
save dashboard changes and makes Grafana show Unauthorized banners for the user specific parts
of its own API. Adding GF_AUTH_ANONYMOUS_ENABLED: "true" to the environment of the grafana
service in docker-compose.yml enables the login free variant with those limitations.
To see which containers Alloy discovered and what the pipeline does to a log line, open the Alloy interface at http://alloy.local.
After changing config.alloy, recreate the container instead of restarting it:
docker compose up -d --force-recreate alloy
The file is bind mounted as a single file, so an editor that writes a new file instead of changing the existing one leaves the running container with the old content. Alloy also remembers how far it has read, which means only log lines written after the recreate carry new labels.
Note
Since all containers of the project are collected, loki, alloy and grafana log their own
output into Loki as well. If Loki is unreachable, Alloy keeps logging that error and picks it up again on the next
run, so it is normal to see a burst of Alloy errors after Loki was restarted.