Cachet publishes service status, incidents, and subscriber notifications. Because it has both public pages and privileged incident management interfaces, hardening must separate read/public and write/admin surfaces. This guide provides security recommendations for production deployments.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Internet β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Reverse Proxy (Nginx/Traefik) β
β β’ TLS 1.3 Termination β
β β’ Rate Limiting β
β β’ Security Headers β
β β’ WAF Rules β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββ΄ββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββ βββββββββββββββββββββββββ
β Public Status Page β β Admin Dashboard β
β (Read-Only) β β (Authenticated) β
β Port 80/443 β β Restricted Access β
βββββββββββββββββββββββββ βββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer β
β β’ Cachet (PHP/Laravel) β
β β’ Authentication & Authorization β
β β’ Input Validation β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββ΄ββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββ βββββββββββββββββββββββββ
β Database β β Redis β
β (MySQL/PostgreSQL) β β (Cache/Session) β
β Private Network β β Private Network β
βββββββββββββββββββββββββ βββββββββββββββββββββββββ
# Reset and enable UFW
sudo ufw --force reset
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH (change port if using non-standard)
sudo ufw allow 22/tcp comment 'SSH'
# Allow HTTP/HTTPS for reverse proxy
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
# Enable UFW
sudo ufw enable
sudo ufw status verbose
# Install and start firewalld
sudo dnf install -y firewalld
sudo systemctl enable --now firewalld
# Set default zone
sudo firewall-cmd --set-default-zone=public
# Allow services
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
# Block direct database access
sudo firewall-cmd --permanent --remove-service=mysql
sudo firewall-cmd --permanent --remove-service=postgresql
# Apply changes
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
# docker-compose.yml - Network configuration
networks:
cachet_public:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
cachet_private:
driver: bridge
internal: true # No external access
ipam:
config:
- subnet: 172.29.0.0/16
services:
cachet:
networks:
- cachet_public
- cachet_private
db:
networks:
- cachet_private # No public access
redis:
networks:
- cachet_private # No public access
# /etc/nginx/sites-available/cachet
server {
listen 80;
listen [::]:80;
server_name status.example.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name status.example.com;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/status.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/status.example.com/privkey.pem;
# TLS 1.3 Only
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
# Security Headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self';" always;
# Hide Nginx version
server_tokens off;
# Logging
access_log /var/log/nginx/cachet_access.log;
error_log /var/log/nginx/cachet_error.log;
# Rate Limiting
limit_req_zone $binary_remote_addr zone=cachet_limit:10m rate=10r/s;
limit_req zone=cachet_limit burst=20 nodelay;
# Proxy to Cachet
location / {
proxy_pass http://127.0.0.1:8080;
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-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Timeout settings
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Admin area restriction (optional - limit by IP)
location /dashboard {
# Allow only specific IPs
allow 203.0.113.0/24; # Your office IP range
allow 198.51.100.50; # Your admin IP
deny all;
proxy_pass http://127.0.0.1:8080;
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;
}
# Block sensitive paths
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~ /(\.env|\.git|composer\.(json|lock)) {
deny all;
return 404;
}
}
# Install Certbot
sudo apt-get install -y certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d status.example.com --agree-tos --email admin@example.com
# Auto-renewal (already configured by Certbot)
sudo certbot renew --dry-run
services:
cachet:
image: cachethq/docker:2.4.1
container_name: cachet
restart: unless-stopped
# Security: Run as non-root user
user: "1000:1000"
# Security: Drop all capabilities, add only needed
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
# Security: Read-only root filesystem
read_only: true
# Security: No new privileges
security_opt:
- no-new-privileges:true
# Security: Disable privilege escalation
privileged: false
# Temporary filesystems for writable directories
tmpfs:
- /tmp:noexec,nosuid,size=100m
- /var/www/html/storage/framework/cache:noexec,nosuid,size=100m
- /var/www/html/storage/framework/sessions:noexec,nosuid,size=100m
- /var/www/html/storage/framework/views:noexec,nosuid,size=100m
- /var/www/html/storage/logs:noexec,nosuid,size=100m
# Resource limits
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
# Health check
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
db:
image: mariadb:10.11
container_name: cachet-db
restart: unless-stopped
# Security: Non-root user
user: "999:999"
# Security: Drop capabilities
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
# Security: Read-only root filesystem
read_only: true
# Security: No new privileges
security_opt:
- no-new-privileges:true
# Temporary filesystems
tmpfs:
- /tmp:noexec,nosuid,size=50m
- /var/run:noexec,nosuid,size=10m
# Resource limits
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.25'
memory: 512M
redis:
image: redis:7-alpine
container_name: cachet-redis
restart: unless-stopped
# Security: Non-root user
user: "999:999"
# Security: Drop capabilities
cap_drop:
- ALL
# Security: Read-only root filesystem
read_only: true
# Security: No new privileges
security_opt:
- no-new-privileges:true
# Resource limits
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
# docker-compose.yml with secrets
services:
cachet:
image: cachethq/docker:2.4.1
secrets:
- db_password
- redis_password
- app_key
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
- REDIS_PASSWORD_FILE=/run/secrets/redis_password
- APP_KEY_FILE=/run/secrets/app_key
secrets:
db_password:
file: ./secrets/db_password.txt
redis_password:
file: ./secrets/redis_password.txt
app_key:
file: ./secrets/app_key.txt
# Create secrets directory and files
mkdir -p secrets
openssl rand -base64 32 > secrets/db_password.txt
openssl rand -base64 32 > secrets/redis_password.txt
docker run --rm cachethq/docker:2.4.1 php artisan key:generate --show | cut -d: -f2 > secrets/app_key.txt
# Set restrictive permissions
chmod 600 secrets/*.txt
# /etc/apparmor.d/docker-cachet
#include <tunables/global>
profile docker-cachet flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network inet tcp,
network inet udp,
network inet icmp,
deny network raw,
deny network packet,
file,
unix,
# Allow read-only access to /etc/hosts
/etc/hosts r,
# Deny access to sensitive files
deny /etc/shadow r,
deny /etc/passwd w,
deny /etc/sudoers r,
# Allow necessary writes
/var/www/html/storage/** rw,
/tmp/** rw,
}
# Load AppArmor profile
sudo apparmor_parser -r /etc/apparmor.d/docker-cachet
# Apply to container
docker run --security-opt apparmor=docker-cachet ...
Enforce strong passwords for admin accounts:
# Enable 2FA in .env
CACHET_2FA_ENABLED=true
CACHET_2FA_REQUIRED=true
# Secure session configuration
SESSION_SECURE_COOKIE=true
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=strict
SESSION_LIFETIME=60
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
// config/sanctum.php
'throttle' => [
'api' => [
['limit' => 60, 'decay' => 1], // 60 requests per minute
],
],
# Generate API key
docker compose exec cachet php artisan cachet:api-key:create --name="Monitoring Integration"
# List API keys
docker compose exec cachet php artisan cachet:api-key:list
# Revoke compromised key
docker compose exec cachet php artisan cachet:api-key:revoke --id=KEY_ID
Ensure all user inputs are validated:
-- Remove anonymous users
DELETE FROM mysql.user WHERE User='';
-- Remove test database
DROP DATABASE IF EXISTS test;
-- Disable local file access
SET GLOBAL local_infile=0;
-- Restrict file permissions
SET GLOBAL secure_file_priv='/var/lib/mysql-files';
-- Enable logging
SET GLOBAL general_log=1;
SET GLOBAL general_log_file='/var/log/mysql/mysql.log';
# /etc/mysql/mariadb.conf.d/99-hardening.cnf
[mysqld]
# Network security
bind-address = 127.0.0.1
skip-networking = 0
skip-symbolic-links = 1
# Security
secure-file-priv = /var/lib/mysql-files
local-infile = 0
# Logging
log-error = /var/log/mysql/error.log
general_log = 1
general_log_file = /var/log/mysql/mysql.log
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
# Resource limits
max_connections = 100
max_user_connections = 50
-- Create restricted user
CREATE USER 'cachet'@'172.29.%' IDENTIFIED BY 'SecurePassword';
-- Grant only necessary privileges
GRANT SELECT, INSERT, UPDATE, DELETE ON cachet.* TO 'cachet'@'172.29.%';
GRANT CREATE, ALTER, INDEX ON cachet.* TO 'cachet'@'172.29.%';
GRANT LOCK TABLES ON cachet.* TO 'cachet'@'172.29.%';
-- Do NOT grant these:
-- - FILE (prevents reading/writing arbitrary files)
-- - PROCESS (prevents viewing other processes)
-- - SUPER (prevents server configuration changes)
-- - GRANT OPTION (prevents privilege escalation)
FLUSH PRIVILEGES;
# Enable detailed logging
LOG_CHANNEL=stack
LOG_LEVEL=info
LOG_DEPRECATIONS_CHANNEL=null
# docker-compose.yml - Add logging driver
services:
cachet:
logging:
driver: syslog
options:
syslog-address: "udp://localhost:514"
tag: "cachet"
syslog-facility: "local0"
# /etc/rsyslog.d/cachet-security.conf
# Forward Cachet logs to SIEM
local0.* @siem.example.com:514
# Alert on failed login attempts
:msg, contains, "Failed login" /var/log/cachet-security.log
# Fail2ban configuration for Cachet
# /etc/fail2ban/jail.d/cachet.conf
[cachet-auth]
enabled = true
port = http,https
filter = cachet-auth
logpath = /var/log/nginx/cachet_access.log
maxretry = 5
bantime = 3600
findtime = 600
# /etc/fail2ban/filter.d/cachet-auth.conf
[Definition]
failregex = ^<HOST> -.*"(POST|GET) /dashboard.*" (401|403)
ignoreregex =
#!/bin/bash
# /usr/local/bin/cachet-backup-encrypted.sh
BACKUP_DIR="/backup/cachet"
DATE=$(date +%Y%m%d_%H%M%S)
GPG_RECIPIENT="backup-admin@example.com"
# Create backup
mysqldump -h db -u cachet -p'SecurePassword' cachet | \
gzip > "$BACKUP_DIR/cachet-db-$DATE.sql.gz"
# Encrypt backup
gpg --encrypt --recipient "$GPG_RECIPIENT" \
--output "$BACKUP_DIR/cachet-db-$DATE.sql.gz.gpg" \
"$BACKUP_DIR/cachet-db-$DATE.sql.gz"
# Securely delete unencrypted backup
shred -u "$BACKUP_DIR/cachet-db-$DATE.sql.gz"
# Set restrictive permissions
chmod 600 "$BACKUP_DIR/cachet-db-$DATE.sql.gz.gpg"
#!/bin/bash
# Verify backup integrity
BACKUP_FILE="$1"
CHECKSUM_FILE="$BACKUP_FILE.sha256"
# Create checksum
sha256sum "$BACKUP_FILE" > "$CHECKSUM_FILE"
# Verify later
sha256sum -c "$CHECKSUM_FILE"
Any questions?
Feel free to contact us. Find all contact information on our contact page.