Cachet is an open-source status page that shows the health of your services, lists incidents and scheduled maintenance, and emails subscribers when something changes. In this tutorial you will run Cachet on Ubuntu 24.04 with Docker Compose and PostgreSQL, put it behind Nginx with a Let's Encrypt certificate, and then manage components, incidents and metrics through the Cachet REST API.
This guide uses the official cachethq/docker image, which ships Cachet 2.3. That image has not been rebuilt in several years and runs on PHP 7, so keep it on its own host or VM, expose it only through the reverse proxy shown here, and treat the admin dashboard as a sensitive endpoint.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS with at least 1 GB of RAM, for example a CubePath VPS. Ideally this is not the same machine that runs the services you are reporting on, so the status page stays up when they go down.
- A non-root user with
sudoprivileges and UFW enabled with OpenSSH allowed. - Docker Engine and the Docker Compose plugin installed from Docker's official repository.
- A domain name with an
Arecord for a subdomain such asstatus.your_domainpointing to your server's IP address. - SMTP credentials if you want Cachet to email subscribers (optional).
Throughout the guide, replace status.your_domain with your own hostname.
Step 1 - Creating the project directory and secrets
Keep the Compose file and its secrets together in /opt/cachet:
sudo mkdir -p /opt/cachet
cd /opt/cachet
Cachet needs a Laravel application key (32 random bytes, base64 encoded, with a base64: prefix) and a database password. Generate both and store them in a .env file that Docker Compose reads automatically:
echo "APP_KEY=base64:$(openssl rand -base64 32)" | sudo tee /opt/cachet/.env > /dev/null
echo "DB_PASSWORD=$(openssl rand -hex 24)" | sudo tee -a /opt/cachet/.env > /dev/null
sudo chmod 600 /opt/cachet/.env
If you want email notifications, append your SMTP settings to the same file:
sudo nano /opt/cachet/.env
MAIL_HOST=smtp.your_provider.com
MAIL_PORT=587
MAIL_USERNAME=your_smtp_user
MAIL_PASSWORD=your_smtp_password
MAIL_ADDRESS=status@your_domain
Importantthe container's entrypoint writes these values into Cachet's configuration with
sed, so avoid commas and&characters in passwords.
Check the file:
sudo cat /opt/cachet/.env
APP_KEY=base64:3kqJ0q0fY1m2Vv8oR8yQ1a7m2kz9C4Hc8b6YVbq0v1c=
DB_PASSWORD=5f1c0e9d2b7a4c3e8f6a1b2c3d4e5f60718293a4b5c6d7e8
...
Step 2 - Trusting the reverse proxy
Cachet 2.3 only trusts Cloudflare's IP ranges as proxies. Behind Nginx it would therefore think every request arrived over plain HTTP and generate http:// links on an HTTPS page. Override its trusted proxy configuration so it accepts the X-Forwarded-* headers coming from the Docker network:
sudo nano /opt/cachet/trustedproxy.php
<?php
use Illuminate\Http\Request;
return [
// Docker's default bridge networks live inside 172.16.0.0/12.
'proxies' => ['172.16.0.0/12'],
'headers' => [
Request::HEADER_CLIENT_IP => 'X_FORWARDED_FOR',
Request::HEADER_CLIENT_HOST => 'X_FORWARDED_HOST',
Request::HEADER_CLIENT_PROTO => 'X_FORWARDED_PROTO',
Request::HEADER_CLIENT_PORT => 'X_FORWARDED_PORT',
],
];
You will mount this file over the one inside the image in the next step.
Step 3 - Writing the Compose file
Create compose.yaml:
sudo nano /opt/cachet/compose.yaml
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: cachet
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: cachet
volumes:
- postgres_data:/var/lib/postgresql/data
cachet:
image: cachethq/docker:2.3.18
restart: unless-stopped
depends_on:
- postgres
ports:
- "127.0.0.1:8000:8000"
volumes:
- ./trustedproxy.php:/var/www/html/config/trustedproxy.php:ro
environment:
APP_KEY: ${APP_KEY}
APP_ENV: production
APP_DEBUG: "false"
APP_URL: https://status.your_domain
APP_LOG: errorlog
DB_DRIVER: pgsql
DB_HOST: postgres
DB_PORT: "5432"
DB_DATABASE: cachet
DB_USERNAME: cachet
DB_PASSWORD: ${DB_PASSWORD}
DB_PREFIX: chq_
CACHET_BEACON: "false"
MAIL_DRIVER: smtp
MAIL_HOST: ${MAIL_HOST:-localhost}
MAIL_PORT: ${MAIL_PORT:-25}
MAIL_USERNAME: ${MAIL_USERNAME:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
MAIL_ADDRESS: ${MAIL_ADDRESS:-}
MAIL_NAME: Status Page
MAIL_ENCRYPTION: tls
volumes:
postgres_data:
A few details worth knowing:
- The port is published on
127.0.0.1only. Docker writes its own firewall rules and bypasses UFW, so binding to localhost is what keeps port 8000 off the internet. Nginx will be the only public entry point. CACHET_BEACON: "false"stops Cachet from sending anonymous usage data to its developers.- The container runs Nginx, PHP-FPM and a queue worker (used for subscriber emails) under supervisord, so no extra services are needed.
Validate the file and pull the images:
sudo docker compose -f /opt/cachet/compose.yaml config --quiet
sudo docker compose -f /opt/cachet/compose.yaml pull
No output from the first command means the syntax is valid.
Step 4 - Starting Cachet
Start both containers in the background:
cd /opt/cachet
sudo docker compose up -d
On the first start, the entrypoint waits for PostgreSQL, runs cachet:install to create the tables and then launches the web server. Follow the log until it reaches the last line:
sudo docker compose logs -f cachet
cachet-1 | Initializing Cachet container ...
cachet-1 | Attempting to connect to database ...
cachet-1 | Table chq_sessions does not exist! ...
cachet-1 | Initializing Cachet database ...
...
cachet-1 | Starting Cachet! ...
Press CTRL+C to stop following the log. Then confirm the API answers locally:
curl -s http://127.0.0.1:8000/api/v1/ping
{"data":"Pong!"}
Step 5 - Publishing Cachet with Nginx and HTTPS
Install Nginx and Certbot:
sudo apt update
sudo apt install nginx certbot python3-certbot-nginx
Create a server block for the status page:
sudo nano /etc/nginx/sites-available/cachet
server {
listen 80;
listen [::]:80;
server_name status.your_domain;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
}
}
Enable it, test the syntax and reload Nginx:
sudo ln -s /etc/nginx/sites-available/cachet /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Open HTTP and HTTPS in UFW, then request a certificate. Certbot edits the server block to add TLS and an HTTP to HTTPS redirect:
sudo ufw allow 'Nginx Full'
sudo certbot --nginx -d status.your_domain
Verify that the site now answers over HTTPS:
curl -sI https://status.your_domain/setup | head -n 1
HTTP/1.1 200 OK
Step 6 - Running the setup wizard
Open https://status.your_domain/setup in your browser. The wizard has three screens:
- Environment setup: choose the cache, session and mail drivers. Keep APC(u) for cache and session, and select SMTP with the same values you put in
.env(or Log (Testing) if you skipped email). The values incompose.yamlare what survive container re-creation, so keep both in sync. - Status page setup: set the site name, the domain
https://status.your_domain, the timezone and the language. - Administrator account: create the admin username, email address and a strong password.
After you click Complete Setup, go to https://status.your_domain/dashboard and log in.
Next, get an API token. Click your avatar in the dashboard sidebar to open your profile; the API Token field contains the token that the API expects in the X-Cachet-Token header. Export it in your shell for the next steps:
export CACHET_URL="https://status.your_domain/api/v1"
export CACHET_TOKEN="your_api_token"
Step 7 - Creating components and groups
Components are the things your users care about: the website, the API, the control panel. Groups collapse related components under one heading. You can create both from Dashboard > Components, or through the API.
Create a group:
curl -s -X POST "$CACHET_URL/components/groups" \
-H "X-Cachet-Token: $CACHET_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Core services", "order": 1, "collapsed": 0}'
The response contains the new group's id. Use it as group_id when creating a component:
curl -s -X POST "$CACHET_URL/components" \
-H "X-Cachet-Token: $CACHET_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "API", "description": "Public REST API", "status": 1, "group_id": 1, "link": "https://api.your_domain", "enabled": true}'
Component status codes are:
| Code | Meaning |
|---|---|
| 1 | Operational |
| 2 | Performance issues |
| 3 | Partial outage |
| 4 | Major outage |
List the components to confirm they exist. Read endpoints do not need a token:
curl -s "$CACHET_URL/components" | python3 -m json.tool | grep -E '"(id|name|status_name)"'
"id": 1,
"name": "API",
"status_name": "Operational",
Step 8 - Reporting and resolving incidents
An incident shows up on the public page, can change a component's status at the same time and, with notify enabled, emails subscribers. Incident status codes are 0 scheduled, 1 investigating, 2 identified, 3 watching and 4 fixed.
Open an incident that marks component 1 as having performance issues:
curl -s -X POST "$CACHET_URL/incidents" \
-H "X-Cachet-Token: $CACHET_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Elevated API latency", "message": "We are investigating slow responses from the API.", "status": 1, "visible": 1, "component_id": 1, "component_status": 2, "notify": true}'
As you learn more, update the same incident with its id:
curl -s -X PUT "$CACHET_URL/incidents/1" \
-H "X-Cachet-Token: $CACHET_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": 2, "message": "The cause is a saturated database connection pool. A fix is being deployed."}'
When the problem is gone, mark the incident as fixed and set the component back to operational:
curl -s -X PUT "$CACHET_URL/incidents/1" \
-H "X-Cachet-Token: $CACHET_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": 4, "component_id": 1, "component_status": 1, "message": "Latency is back to normal. We will keep monitoring."}'
Reload https://status.your_domain to see the incident in the timeline and the component back in green.
Step 9 - Adding subscribers
Visitors can subscribe from the Subscribe button on the public page and confirm through the email Cachet sends them. You can also add a subscriber through the API. Setting verify to true skips the confirmation email:
curl -s -X POST "$CACHET_URL/subscribers" \
-H "X-Cachet-Token: $CACHET_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "oncall@your_domain", "verify": true}'
If subscribers do not receive mail, check the container log for SMTP errors with sudo docker compose logs cachet.
Step 10 - Publishing a metric automatically
Metrics draw a chart on the status page, for example the response time of your API. Create one with calc_type set to 1 so Cachet averages the points it receives (0 sums them):
curl -s -X POST "$CACHET_URL/metrics" \
-H "X-Cachet-Token: $CACHET_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "API response time", "suffix": "ms", "description": "Time to answer GET /health", "default_value": 0, "calc_type": 1, "display_chart": true}'
To feed it, run a small script from cron once a minute. First store the token in a root-only file so it does not end up in the script or in the process list of other users:
sudo nano /etc/cachet-metric.env
CACHET_URL=https://status.your_domain/api/v1
CACHET_TOKEN=your_api_token
METRIC_ID=1
CHECK_URL=https://api.your_domain/health
sudo chmod 600 /etc/cachet-metric.env
Now create the script:
sudo nano /usr/local/bin/cachet-metric
#!/usr/bin/env bash
# Measure the response time of CHECK_URL and send it to a Cachet metric.
set -euo pipefail
# shellcheck source=/dev/null
source /etc/cachet-metric.env
seconds=$(curl -fsS -o /dev/null --max-time 10 -w '%{time_total}' "$CHECK_URL")
ms=$(awk -v s="$seconds" 'BEGIN { printf "%d", s * 1000 }')
curl -fsS -o /dev/null -X POST "${CACHET_URL}/metrics/${METRIC_ID}/points" \
-H "X-Cachet-Token: ${CACHET_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"value\": ${ms}, \"timestamp\": $(date +%s)}"
Make it executable and run it once by hand. It prints nothing on success and exits non-zero if either request fails:
sudo chmod 700 /usr/local/bin/cachet-metric
sudo /usr/local/bin/cachet-metric && echo OK
OK
Schedule it with a cron file:
echo '* * * * * root /usr/local/bin/cachet-metric' | sudo tee /etc/cron.d/cachet-metric
After a few minutes, the chart appears under the components on the public page.
Troubleshooting
The container exits right after starting and the log says Please set the 'APP_KEY=...'. The APP_KEY variable is empty or null. Check that /opt/cachet/.env contains a line starting with APP_KEY=base64: and that you ran docker compose from /opt/cachet.
The log keeps printing dots after Attempting to connect to database. PostgreSQL is not reachable or the password differs. Look at sudo docker compose logs postgres. If you changed DB_PASSWORD after the first start, PostgreSQL still uses the old one because it is stored in the volume.
Links or form actions point to http:// and the login loops. The trusted proxy override is not being applied. Confirm the file is mounted with sudo docker compose exec cachet cat /var/www/html/config/trustedproxy.php, and check that your Docker networks are inside 172.16.0.0/12 with sudo docker network inspect cachet_default | grep Subnet. Recreate the container after any change with sudo docker compose up -d --force-recreate cachet.
A 502 Bad Gateway from Nginx. Cachet is not listening yet or crashed. Run sudo docker compose ps and curl -s http://127.0.0.1:8000/api/v1/ping to see which side is failing.
Conclusion
You now have Cachet running with PostgreSQL on Ubuntu 24.04, served over HTTPS by Nginx, with components, incidents, subscribers and an automatically updated metric. From here you can call the same API endpoints from your monitoring system to switch component states when a check fails, back up the postgres_data volume with pg_dump on a schedule, and host the status page in a different location from your main infrastructure so it stays reachable during an outage.
