This guide provides a Ansible playbook for deploying Cachet with Docker Compose on Debian, Ubuntu, and RHEL-compatible systems. The playbook includes security hardening, backup configuration, and production-ready settings.
| Distribution | Version | Notes |
|---|---|---|
| Debian | 11 (Bullseye), 12 (Bookworm) | Recommended: Debian 12 |
| Ubuntu | 22.04 LTS, 24.04 LTS | Recommended: 24.04 LTS |
| RHEL | 9.x | Requires subscription |
| AlmaLinux | 9.x | RHEL-compatible |
| Rocky Linux | 9.x | RHEL-compatible |
| Component | Minimum | Recommended |
|---|---|---|
| CPU | 2 cores | 4 cores |
| RAM | 2 GB | 4 GB |
| Storage | 20 GB | 50 GB SSD |
cachet-ansible/
βββ inventory/
β βββ hosts.yml
β βββ group_vars/
β βββ cachet.yml
βββ playbooks/
β βββ deploy.yml
β βββ backup.yml
β βββ update.yml
βββ roles/
β βββ docker/
β βββ cachet/
β βββ nginx/
β βββ security/
βββ templates/
β βββ docker-compose.yml.j2
β βββ .env.j2
β βββ nginx.conf.j2
βββ files/
β βββ scripts/
β βββ backup.sh
βββ ansible.cfg
βββ requirements.yml
---
all:
children:
cachet:
hosts:
cachet-prod-01:
ansible_host: 192.168.1.100
app_domain: status.example.com
app_name: "Production Status"
cachet-staging-01:
ansible_host: 192.168.1.101
app_domain: status-staging.example.com
app_name: "Staging Status"
---
# Application Settings
cachet_version: "2.4.1"
cachet_image: "cachethq/docker:{{ cachet_version }}"
app_environment: production
app_debug: false
app_timezone: "Europe/Berlin"
app_locale: "en"
# Database Settings
db_type: mariadb
db_version: "10.11"
db_name: cachet
db_user: cachet
db_password: "{{ vault_db_password }}"
# Redis Settings
redis_version: "7-alpine"
redis_password: "{{ vault_redis_password }}"
# Docker Settings
docker_compose_version: "v2.24.0"
docker_network_name: cachet_network
# Nginx Settings
nginx_enabled: true
nginx_ssl_enabled: true
ssl_certificate_path: "/etc/letsencrypt/live/{{ app_domain }}"
# Backup Settings
backup_enabled: true
backup_retention_days: 30
backup_directory: "/backup/cachet"
backup_schedule: "0 2 * * *" # Daily at 2:00 AM
# Security Settings
firewall_enabled: true
fail2ban_enabled: true
auto_updates_enabled: true
# Resource Limits
cachet_cpu_limit: "1.0"
cachet_memory_limit: "512M"
db_cpu_limit: "1.0"
db_memory_limit: "1G"
redis_cpu_limit: "0.5"
redis_memory_limit: "256M"
Create group_vars/cachet-vault.yml and encrypt with ansible-vault:
---
vault_db_password: "Str0ng!DbP@ssw0rd#2026"
vault_redis_password: "Str0ng!RedisP@ss#2026"
vault_app_key: "base64:xJ8K9mN2pQ4rT6vW8yZ0aB3cD5eF7gH9i=="
vault_mail_password: "MailP@ssw0rd#2026"
# Encrypt the vault file
ansible-vault encrypt inventory/group_vars/cachet-vault.yml
---
- name: Deploy Cachet Status Page
hosts: cachet
become: true
gather_facts: true
vars_files:
- ../inventory/group_vars/cachet.yml
- ../inventory/group_vars/cachet-vault.yml
pre_tasks:
- name: Validate Ansible version
ansible.builtin.assert:
that:
- ansible_version.full is version('2.14', '>=')
fail_msg: "Ansible 2.14+ is required"
- name: Update apt cache (Debian/Ubuntu)
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Update dnf cache (RHEL)
ansible.builtin.dnf:
update_cache: true
when: ansible_os_family == "RedHat"
roles:
- role: docker
tags: ['docker', 'base']
- role: security
tags: ['security', 'base']
- role: cachet
tags: ['cachet', 'app']
- role: nginx
tags: ['nginx', 'proxy']
when: nginx_enabled | bool
post_tasks:
- name: Verify Cachet is running
ansible.builtin.command:
cmd: docker compose ps
chdir: "{{ app_root }}"
register: compose_status
changed_when: false
failed_when: "'cachet' not in compose_status.stdout"
- name: Display deployment summary
ansible.builtin.debug:
msg: |
Cachet Deployment Complete!
===========================
Domain: https://{{ app_domain }}
Version: {{ cachet_version }}
Database: {{ db_type }} {{ db_version }}
Backup: {{ backup_directory }}
Next Steps:
1. Complete setup wizard at https://{{ app_domain }}/setup
2. Configure SSL certificate with Certbot
3. Set up monitoring and alerts
---
- name: Install Docker prerequisites
ansible.builtin.package:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
state: present
when: ansible_os_family == "Debian"
- name: Add Docker GPG key (Debian/Ubuntu)
ansible.builtin.apt_key:
url: https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg
state: present
when: ansible_os_family == "Debian"
- name: Add Docker repository (Debian/Ubuntu)
ansible.builtin.apt_repository:
repo: "deb [arch=amd64] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable"
state: present
filename: docker
when: ansible_os_family == "Debian"
- name: Install Docker packages
ansible.builtin.package:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
notify: Restart Docker
- name: Enable and start Docker service
ansible.builtin.systemd:
name: docker
state: started
enabled: true
- name: Create Docker configuration directory
ansible.builtin.file:
path: /etc/docker
state: directory
mode: '0755'
- name: Configure Docker daemon
ansible.builtin.copy:
dest: /etc/docker/daemon.json
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"userland-proxy": false
}
mode: '0644'
notify: Restart Docker
- name: Add user to docker group
ansible.builtin.user:
name: "{{ ansible_user_id }}"
groups: docker
append: true
---
- name: Create application directory
ansible.builtin.file:
path: "{{ app_root }}"
state: directory
mode: '0755'
owner: root
group: root
- name: Create secrets directory
ansible.builtin.file:
path: "{{ app_root }}/secrets"
state: directory
mode: '0700'
owner: root
group: root
- name: Generate database password if not provided
ansible.builtin.set_fact:
vault_db_password: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters,digits') }}"
when: vault_db_password is not defined
- name: Generate Redis password if not provided
ansible.builtin.set_fact:
vault_redis_password: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters,digits') }}"
when: vault_redis_password is not defined
- name: Generate Laravel app key if not provided
ansible.builtin.set_fact:
vault_app_key: "base64:{{ lookup('password', '/dev/null length=24 chars=ascii_letters,digits') }}=="
when: vault_app_key is not defined
- name: Create secret files
ansible.builtin.copy:
dest: "{{ app_root }}/secrets/{{ item.name }}"
content: "{{ item.content }}"
mode: '0600'
loop:
- { name: 'db_password.txt', content: "{{ vault_db_password }}" }
- { name: 'redis_password.txt', content: "{{ vault_redis_password }}" }
- { name: 'app_key.txt', content: "{{ vault_app_key }}" }
- name: Deploy Docker Compose configuration
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ app_root }}/docker-compose.yml"
mode: '0644'
notify: Restart Cachet
- name: Deploy environment configuration
ansible.builtin.template:
src: .env.j2
dest: "{{ app_root }}/.env"
mode: '0600'
notify: Restart Cachet
- name: Deploy database environment configuration
ansible.builtin.template:
src: .env.db.j2
dest: "{{ app_root }}/.env.db"
mode: '0600'
notify: Restart Cachet
- name: Create backup directory
ansible.builtin.file:
path: "{{ backup_directory }}"
state: directory
mode: '0750'
owner: root
group: root
when: backup_enabled | bool
- name: Deploy backup script
ansible.builtin.template:
src: backup.sh.j2
dest: /usr/local/bin/cachet-backup.sh
mode: '0755'
when: backup_enabled | bool
- name: Configure backup cron job
ansible.builtin.cron:
name: "Cachet daily backup"
minute: "0"
hour: "2"
job: "/usr/local/bin/cachet-backup.sh >> /var/log/cachet-backup.log 2>&1"
user: root
when: backup_enabled | bool
- name: Start Cachet stack
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ app_root }}"
register: compose_up
changed_when: "'Creating' in compose_up.stdout or 'Starting' in compose_up.stdout"
- name: Wait for Cachet to be healthy
ansible.builtin.wait_for:
timeout: 60
delay: 5
when: compose_up.changed
- name: Pull latest Cachet image
ansible.builtin.command:
cmd: docker compose pull cachet
chdir: "{{ app_root }}"
changed_when: false
---
services:
cachet:
image: {{ cachet_image }}
container_name: cachet
restart: unless-stopped
ports:
- "127.0.0.1:8080:8000"
env_file:
- .env
secrets:
- db_password
- redis_password
- app_key
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
- cachet_data:/var/www/html
networks:
- cachet_public
- cachet_private
security_opt:
- no-new-privileges:true
read_only: true
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
deploy:
resources:
limits:
cpus: '{{ cachet_cpu_limit }}'
memory: {{ cachet_memory_limit }}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
db:
image: {{ db_type }}:{{ db_version }}
container_name: cachet-db
restart: unless-stopped
env_file:
- .env.db
secrets:
- db_password
volumes:
- cachet_db:/var/lib/mysql
networks:
- cachet_private
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=50m
- /var/run:noexec,nosuid,size=10m
deploy:
resources:
limits:
cpus: '{{ db_cpu_limit }}'
memory: {{ db_memory_limit }}
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:{{ redis_version }}
container_name: cachet-redis
restart: unless-stopped
command: redis-server --appendonly yes
secrets:
- redis_password
volumes:
- cachet_redis:/data
networks:
- cachet_private
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=50m
deploy:
resources:
limits:
cpus: '{{ redis_cpu_limit }}'
memory: {{ redis_memory_limit }}
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
cachet_data:
cachet_db:
cachet_redis:
networks:
cachet_public:
driver: bridge
cachet_private:
driver: bridge
internal: true
secrets:
db_password:
file: ./secrets/db_password.txt
redis_password:
file: ./secrets/redis_password.txt
app_key:
file: ./secrets/app_key.txt
---
- name: Install security packages
ansible.builtin.package:
name:
- ufw
- fail2ban
- unattended-upgrades
- apt-listchanges
state: present
when: ansible_os_family == "Debian"
- name: Configure UFW firewall
ansible.builtin.ufw:
rule: "{{ item.rule }}"
port: "{{ item.port }}"
proto: "{{ item.proto }}"
comment: "{{ item.comment }}"
loop:
- { rule: 'allow', port: '22', proto: 'tcp', comment: 'SSH' }
- { rule: 'allow', port: '80', proto: 'tcp', comment: 'HTTP' }
- { rule: 'allow', port: '443', proto: 'tcp', comment: 'HTTPS' }
when: firewall_enabled | bool
- name: Enable UFW
ansible.builtin.ufw:
state: enabled
policy: deny
when: firewall_enabled | bool
- name: Configure Fail2ban
ansible.builtin.template:
src: jail.local.j2
dest: /etc/fail2ban/jail.local
mode: '0644'
notify: Restart fail2ban
when: fail2ban_enabled | bool
- name: Enable and start Fail2ban
ansible.builtin.systemd:
name: fail2ban
state: started
enabled: true
when: fail2ban_enabled | bool
- name: Configure automatic security updates
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/50unattended-upgrades
content: |
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";
mode: '0644'
when: ansible_os_family == "Debian" and auto_updates_enabled | bool
- name: Set sysctl security parameters
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
sysctl_set: true
state: present
reload: true
loop:
- { name: 'net.ipv4.ip_forward', value: '0' }
- { name: 'net.ipv4.conf.all.send_redirects', value: '0' }
- { name: 'net.ipv4.conf.default.send_redirects', value: '0' }
- { name: 'net.ipv4.conf.all.accept_source_route', value: '0' }
- { name: 'net.ipv4.conf.default.accept_source_route', value: '0' }
- { name: 'net.ipv4.conf.all.accept_redirects', value: '0' }
- { name: 'net.ipv4.conf.default.accept_redirects', value: '0' }
- { name: 'net.ipv4.conf.all.log_martians', value: '1' }
- { name: 'net.ipv4.icmp_echo_ignore_broadcasts', value: '1' }
---
- name: Install Nginx
ansible.builtin.package:
name: nginx
state: present
- name: Create Nginx configuration directory
ansible.builtin.file:
path: /etc/nginx/sites-available
state: directory
mode: '0755'
- name: Deploy Nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/sites-available/cachet
mode: '0644'
notify: Restart nginx
- name: Enable Nginx site
ansible.builtin.file:
src: /etc/nginx/sites-available/cachet
dest: /etc/nginx/sites-enabled/cachet
state: link
notify: Restart nginx
- name: Remove default Nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Restart nginx
- name: Enable and start Nginx
ansible.builtin.systemd:
name: nginx
state: started
enabled: true
---
- name: Backup Cachet
hosts: cachet
become: true
vars_files:
- ../inventory/group_vars/cachet.yml
tasks:
- name: Run backup script
ansible.builtin.command:
cmd: /usr/local/bin/cachet-backup.sh
register: backup_result
changed_when: true
- name: Display backup status
ansible.builtin.debug:
msg: "Backup completed: {{ backup_result.stdout }}"
---
- name: Update Cachet
hosts: cachet
become: true
vars_files:
- ../inventory/group_vars/cachet.yml
pre_tasks:
- name: Run pre-update backup
ansible.builtin.command:
cmd: /usr/local/bin/cachet-backup.sh
register: backup_result
changed_when: true
tasks:
- name: Pull latest Docker image
ansible.builtin.command:
cmd: docker compose pull cachet
chdir: "{{ app_root }}"
register: pull_result
changed_when: "'Pulling' in pull_result.stdout"
- name: Recreate Cachet container
ansible.builtin.command:
cmd: docker compose up -d --force-recreate cachet
chdir: "{{ app_root }}"
when: pull_result.changed
- name: Verify Cachet is running
ansible.builtin.command:
cmd: docker compose ps
chdir: "{{ app_root }}"
register: compose_status
changed_when: false
failed_when: "'cachet' not in compose_status.stdout"
# Navigate to playbook directory
cd cachet-ansible/playbooks
# Run deployment (provide vault password)
ansible-playbook -i ../inventory/hosts.yml deploy.yml --ask-vault-pass
# Or with vault password file
ansible-playbook -i ../inventory/hosts.yml deploy.yml --vault-password-file=~/.vault-pass
# Run backup
ansible-playbook -i ../inventory/hosts.yml backup.yml
# Update Cachet to latest version
ansible-playbook -i ../inventory/hosts.yml update.yml
# Check what would be changed
ansible-playbook -i ../inventory/hosts.yml deploy.yml --check --diff
# Deploy to specific host
ansible-playbook -i ../inventory/hosts.yml deploy.yml --limit cachet-prod-01
# Deploy with specific tags
ansible-playbook -i ../inventory/hosts.yml deploy.yml --tags 'security,docker'
---
- name: Restart Docker
ansible.builtin.systemd:
name: docker
state: restarted
---
- name: Restart Cachet
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ app_root }}"
---
- name: Restart nginx
ansible.builtin.systemd:
name: nginx
state: restarted
---
- name: Restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
# Check deployment status
ansible all -i inventory/hosts.yml -m shell -a "docker compose ps"
# Verify ports
ansible all -i inventory/hosts.yml -m shell -a "ss -tlnp | grep -E ':(80|443)'"
# Check firewall status
ansible all -i inventory/hosts.yml -m shell -a "ufw status"
# Verify backup cron
ansible all -i inventory/hosts.yml -m shell -a "crontab -l | grep cachet"
Docker not starting:
ansible all -i inventory/hosts.yml -m shell -a "systemctl status docker"
ansible all -i inventory/hosts.yml -m shell -a "journalctl -u docker -n 50"
Cachet container unhealthy:
ansible all -i inventory/hosts.yml -m shell -a "docker compose logs cachet"
Firewall blocking access:
ansible all -i inventory/hosts.yml -m shell -a "ufw status verbose"
Any questions?
Feel free to contact us. Find all contact information on our contact page.