13 Monitoring and Troubleshooting
This chapter covers what to watch on a running instance, where to find logs, and how to fix the failures operators meet most often.
13.1 Health checks
13.1.1 The health endpoint
GET /api/health needs no sign-in and reports whether the application can
reach its database:
| Response | Meaning |
|---|---|
200 with {"status":"ok"} | web is running and the database answered. |
503 with {"status":"unavailable"} | web is running but the database query failed. |
| No response, or a proxy error | web is down or unreachable. |
The endpoint reveals nothing else and is never cached. Point your external
monitoring at the public address, for example
https://grc.example.com/api/health, so it also covers DNS, the proxy and
the certificate.
13.1.2 Container health checks
| Service | Check | Healthy when |
|---|---|---|
db | pg_isready every 5 seconds | The database accepts connections. Up to 20 retries. |
web | wget of http://127.0.0.1:3000/api/health every 15 seconds | The endpoint answers 200. Unhealthy after 5 failures; failures in the first 20 seconds do not count. |
worker | None | Check that it is running and read its log. |
migrate | None | It must exit with code 0. |
docker compose ps shows the state of each service. restart: unless-stopped restarts db, web, worker and caddy if they exit.
Docker does not restart a container that is only unhealthy.
13.1.3 The audit chain
Verify the audit log's hash chain daily from cron, so a break is reported rather than found later:
30 3 * * * cd /srv/grc && docker compose exec -T web ./node_modules/.bin/tsx prisma/audit-verify.ts >> /var/log/grc-audit-verify.log 2>&1 || echo "GRC audit chain verification failed" | mail -s "GRC audit chain" ops@example.com
The command prints the verdict as JSON and exits 0 when the chain is intact,
1 when it is broken and 2 on an error. Each run is itself recorded in the
audit log. Administrators can run the same check under Settings → Audit
Log (/settings/audit-log); see Audit Log and Evidence Integrity.
To detect a rewrite by someone holding the database owner's password, also copy the chain head off the server after each verification:
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT \"lastSeq\", \"lastHash\" FROM audit_chain_head"'
A later rewrite no longer matches a hash recorded elsewhere.
13.1.4 What else to watch
| Watch | How | Why |
|---|---|---|
| Nightly backup | Exit status and log of the cron job; age of the newest file off-site | The backup is your recovery. |
| Disk space | df -h on the Docker data directory and the backup directory | A full disk stops the database. |
| Security alerts | Administrators' mailboxes | Lockouts, break-glass sign-ins and administrator changes are reported only by email. |
| Email delivery | Settings → Notifications delivery list; [mailer] lines in the worker log | Failed deliveries mean reminders and alerts are not arriving. |
| Certificate expiry | External monitoring of the HTTPS address | With your own certificate, renewal is yours to do. |
13.2 Logs
Each service writes to standard output; read it with docker compose logs:
docker compose logs --tail 200 web
docker compose logs -f worker
docker compose logs migrate
docker compose logs caddy
docker compose logs db
| Service | What to look for |
|---|---|
migrate | Prisma's migration output, then app-role: and [bootstrap] lines. An error here is why a start stopped. |
web | Request errors, and structured JSON lines with an event field, such as settings.decrypt_failed and security_alert.failed. |
worker | One [worker] line when it is ready, then a line per daily sweep, weekly backup and scheduled report run; [mailer] delivery <id> failed: for each failed email. |
caddy | Certificate requests and renewals, and upstream errors when web is down. |
db | Connection and authentication errors, disk errors. |
Docker's default log driver keeps container logs without limit. Set a size
limit in /etc/docker/daemon.json, for example:
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "5" }
}
Restart Docker and recreate the containers for it to apply. If your organization collects logs centrally, ship the container logs there; the audit log inside the application remains the record of user activity.
13.3 Row counts
deploy/counts.sql prints the row count of every table in the application
and queue schemas, one schema.table count line each. Take it before and
after any maintenance you do by hand, and compare:
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -F " " -f -' \
< deploy/counts.sql | sort > counts-$(date -u +%Y%m%dT%H%M%SZ).txt
13.4 Common problems
13.4.1 The stack does not start: a variable is not set
Compose prints Set APP_DB_PASSWORD in .env (or AUTH_SECRET, APP_URL,
POSTGRES_PASSWORD) and starts nothing. Add the value to .env and run the
command again.
13.4.2 migrate fails
web and worker stay down until migrate succeeds. Read its log:
docker compose logs migrate
| Message | Cause and fix |
|---|---|
[bootstrap] No users exist. Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD ... | A new database and no initial administrator. Set both in .env (password 12 characters or more) and start again. |
app-role: APP_DB_PASSWORD must be set, 16 characters or more. | Set a longer APP_DB_PASSWORD. |
app-role: APP_DB_USER must differ from the database owner ... or must be a lower-case PostgreSQL identifier | Correct APP_DB_USER. |
app-role: <role> owns <tables> in public; it must own nothing there. | Tables were created by the application role, usually by hand. Transfer their ownership to the owner role and start again. |
An authentication error for the owner role (grc) | POSTGRES_PASSWORD in .env differs from the password the database volume was created with. The postgres image sets the password only when it creates the volume; restore the original value. |
A Prisma migration error (P3009, P3018) | A migration failed part way. Do not edit the database by hand. Keep the volume, take a backup, and contact your support channel with the log; restore the pre-update backup if you need the service back first (see Upgrades). |
To inspect migration state without starting the application, run a command in
a one-off migrate container:
docker compose run --rm --no-deps migrate ./node_modules/.bin/prisma migrate status
13.4.3 web is unhealthy or keeps restarting
docker compose ps web
docker compose logs --tail 100 web
curl -s http://127.0.0.1:3000/api/health
| Symptom | Cause and fix |
|---|---|
Log shows AUTH_SECRET is shorter than 32 characters or AUTH_SECRET is still the .env.example placeholder | In production the application refuses a weak session secret. Generate one with openssl rand -base64 32, set it, and run docker compose up -d. Changing it signs everyone out (see Security Hardening). |
/api/health returns {"status":"unavailable"} | web cannot reach the database. Check docker compose ps db and docker compose logs db; check that APP_DB_PASSWORD has not changed since migrate last ran (docker compose up -d reapplies it). |
| Health is fine locally but the site does not load | The proxy is the problem: docker compose logs caddy, or your proxy's log. Check DNS for DOMAIN and that ports 80 and 443 are open. |
| Sign-in redirects to the wrong address | APP_URL is wrong. Set it to the public address and run docker compose up -d. |
| Form submissions or uploads fail with 403 behind your own proxy | The proxy does not pass the browser's Host header (or X-Forwarded-Host), so the origin check fails. Pass it. |
| Uploads fail with 413 | The proxy's body limit is lower than the application's. Use 32 MiB, and 2049 MiB for /api/backups/restore. |
13.4.4 Caddy does not get a certificate
docker compose logs caddy shows the ACME error. Check that DOMAIN resolves
to this server from the internet, that ports 80 and 443 reach Caddy (no other
service holds them, and no firewall blocks them), and that DOMAIN and
ACME_EMAIL are set. For a server Let's Encrypt cannot reach, use your own
certificate (see Installation).
13.4.5 Email is not sent
- In Settings → Integrations, click Send test email. The error it shows is the SMTP server's answer.
- If the test works but notifications do not arrive, check that the worker
is running (
docker compose ps worker) and read its log for[mailer] delivery <id> failed:lines. Email is sent only by the worker. - A configuration that sends a username and password requires STARTTLS (or implicit TLS) from the relay; a relay that does not offer it is refused rather than sent the password in clear.
- If the web log shows
settings.decrypt_failedforsmtp.pass, the stored password was encrypted with a different key (after a change ofAUTH_SECRETorSETTINGS_ENCRYPTION_KEY, or a restore from another instance). Enter the password again under Settings → Integrations. - Digests arriving at the wrong time after a schedule change: the cron is in
UTC. If saving the schedule warned that it takes effect when the worker
next starts, restart the worker (
docker compose restart worker).
Notification setup and the delivery list are described in Notifications and Email.
13.4.6 Single sign-on errors
A refused LDAP or SAML sign-in shows the user a general message and records
the reason in the audit log. Look up the sign-in failure entry under
Settings → Audit Log, and see Authentication and Single Sign-On for each
reason. On the server side, check that APP_URL uses https:// and matches
the address registered with the identity provider, and that the server clock
is correct: SAML responses older than 10 minutes are refused.
13.4.7 Disk full
A full disk stops PostgreSQL, and with it the application. Find what is using the space:
df -h
docker system df
du -sh /var/backups/grc
Free space by, in order of safety:
- Moving older local backups off the server, or shortening the local retention period.
- Removing old rollback images and the build cache (see Upgrades, Cleaning up after upgrades).
- Limiting container log size (see Logs).
- Deleting in-app snapshots under Settings → Backups, if they are copied elsewhere.
Never remove a Docker volume to free space. After space is freed, run
docker compose --profile tls up -d and check health.
13.4.8 Audit chain verification fails
A broken chain is evidence that an audit entry was changed, deleted or reordered outside the application, which requires the database owner's password or root on the server. The verdict names the first entry that fails and the reason:
| Reason | Meaning |
|---|---|
hash-mismatch | The entry's content no longer matches its stored hash: it was changed. |
prev-hash-mismatch | The entry does not follow the entry before it: an entry was removed or inserted. |
sequence-gap | A sequence number is missing: an entry was deleted. |
unchained | An entry has no chain fields. |
before-anchor | An entry older than the last retention prune is still present. |
head-mismatch | The last entry does not match the recorded chain head. |
Do this:
- Do not repair anything, and stop further writes if you can
(
docker compose stop web worker). - Take a server backup into a separate directory and preserve the database volume as it is.
- Record the verdict and the time, and start your incident process. Compare with the chain heads you copied off the server to find when it changed.
- Restoring an earlier backup brings back an intact trail from that time; decide that as part of the investigation, after the evidence is kept.
13.4.9 Locked out of administrator accounts
Sign in with a break-glass administrator; it keeps password sign-in when SSO only is on and the identity provider is down. Any administrator can reset another user's password under Settings → Users. Repeated failed sign-ins block the address that failed, never the account, so the right password from another network still works. There is no command-line password reset, which is why the post-installation checklist asks for a second break-glass administrator.