This guide provides a Ansible automation playbook for deploying Postiz with Docker Compose on Debian 10+, Ubuntu 20.04+, and RHEL 9+ compatible hosts. The playbook includes security hardening, TLS automation, backup configuration, and monitoring setup.
Install Ansible:
# Debian/Ubuntu
sudo apt-get install -y ansible
# RHEL/CentOS
sudo dnf install -y ansible
# macOS
brew install ansible
# pip
pip3 install ansible
Verify installation:
ansible --version
| Distribution | Minimum Version | Recommended |
|---|---|---|
| Debian | 10 (Buster) | 12 (Bookworm) |
| Ubuntu | 20.04 LTS | 24.04 LTS |
| RHEL | 9+ | 9.3+ |
| Rocky Linux | 9+ | 9.3+ |
| AlmaLinux | 9+ | 9.3+ |
Target host requirements:
Generate SSH key (if not exists):
ssh-keygen -t ed25519 -C "ansible-postiz"
Copy SSH key to target host:
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@postiz-server.example.com
Test SSH connection:
ssh user@postiz-server.example.com
Create inventory.ini:
[postiz_servers]
postiz-server.example.com ansible_user=deploy ansible_ssh_private_key_file=~/.ssh/id_ed25519
[postiz_servers:vars]
ansible_python_interpreter=/usr/bin/python3
ansible_ssh_common_args='-o StrictHostKeyChecking=no'
[all:vars]
# Application settings
postiz_domain=postiz.example.com
postiz_email=admin@example.com
postiz_version=latest
# Database settings
postgres_password=CHANGE_ME_STRONG_PASSWORD
postgres_db=postiz
postgres_user=postiz
# Redis settings
redis_password=CHANGE_ME_STRONG_PASSWORD
# JWT Secret (generate with: openssl rand -base64 48)
jwt_secret=CHANGE_ME_JWT_SECRET_MINIMUM_32_CHARS
# Email notifications
resend_api_key=
from_email=noreply@example.com
# Security settings
enable_ufw=true
enable_fail2ban=true
enable_automatic_updates=true
tls_enabled=true
Create inventory.yml:
all:
children:
postiz_servers:
hosts:
postiz-primary:
ansible_host: 192.168.1.100
ansible_user: deploy
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
postiz_domain: postiz.example.com
postiz-backup:
ansible_host: 192.168.1.101
ansible_user: deploy
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
postiz_domain: postiz-backup.example.com
vars:
ansible_python_interpreter: /usr/bin/python3
postiz_email: admin@example.com
postiz_version: v2.18.0
postgres_password: "{{ vault_postgres_password }}"
redis_password: "{{ vault_redis_password }}"
jwt_secret: "{{ vault_jwt_secret }}"
enable_ufw: true
enable_fail2ban: true
tls_enabled: true
vars:
# Vault variables (store in group_vars/all/vault.yml)
vault_postgres_password: CHANGE_ME
vault_redis_password: CHANGE_ME
vault_jwt_secret: CHANGE_ME
postiz-ansible/
βββ inventory.ini
βββ inventory.yml
βββ postiz-deploy.yml
βββ group_vars/
β βββ all.yml
β βββ vault.yml (encrypted with ansible-vault)
βββ host_vars/
β βββ postiz-server.example.com.yml
βββ roles/
β βββ docker/
β β βββ tasks/
β β βββ main.yml
β βββ security/
β β βββ tasks/
β β β βββ main.yml
β β βββ handlers/
β β βββ main.yml
β βββ postiz/
β β βββ tasks/
β β β βββ main.yml
β β βββ templates/
β β β βββ docker-compose.yml.j2
β β β βββ .env.j2
β β βββ handlers/
β β βββ main.yml
β βββ backup/
β βββ tasks/
β β βββ main.yml
β βββ templates/
β βββ backup.sh.j2
βββ ansible.cfg
---
- name: Deploy Postiz Social Media Scheduler
hosts: postiz_servers
become: true
gather_facts: true
vars:
# Application settings
postiz_domain: "{{ postiz_domain | default('postiz.example.com') }}"
postiz_email: "{{ postiz_email | default('admin@example.com') }}"
postiz_version: "{{ postiz_version | default('latest') }}"
postiz_app_root: /opt/postiz
# Docker settings
docker_compose_version: "2.24.0"
docker_daemon_options:
storage-driver: overlay2
log-driver: json-file
log-opts:
max-size: "10m"
max-file: "3"
# Security settings
enable_ufw: "{{ enable_ufw | default(true) }}"
enable_fail2ban: "{{ enable_fail2ban | default(true) }}"
enable_automatic_updates: "{{ enable_automatic_updates | default(true) }}"
tls_enabled: "{{ tls_enabled | default(true) }}"
# Backup settings
backup_enabled: true
backup_retention_days: 30
backup_time: "02:00"
roles:
- role: docker
tags: ['docker', 'base']
- role: security
tags: ['security', 'hardening']
- role: postiz
tags: ['postiz', 'app']
- role: backup
tags: ['backup']
when: backup_enabled | bool
post_tasks:
- name: Display deployment information
ansible.builtin.debug:
msg: |
Postiz deployment completed!
Application URL: https://{{ postiz_domain }}
Admin Email: {{ postiz_email }}
Next steps:
1. Navigate to https://{{ postiz_domain }}
2. Create your admin account
3. Configure social media integrations
4. Set up email notifications
Useful commands:
- Check status: docker compose ps
- View logs: docker compose logs -f
- Restart: docker compose restart
- Backup: {{ postiz_app_root }}/backup.sh
handlers:
- name: Restart docker
ansible.builtin.systemd:
name: docker
state: restarted
- name: Restart nginx
ansible.builtin.systemd:
name: nginx
state: restarted
[defaults]
inventory = inventory.ini
remote_user = deploy
host_key_checking = False
retry_files_enabled = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 86400
stdout_callback = yaml
callback_whitelist = profile_tasks
roles_path = ./roles
library = ./library
forks = 10
timeout = 30
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
[ssh_connection]
pipelining = True
control_path = /tmp/ansible-%%h-%%p-%%r
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null
---
- name: Install security packages
ansible.builtin.apt:
name:
- ufw
- fail2ban
- unattended-upgrades
- apt-listchanges
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Install security packages (RHEL)
ansible.builtin.dnf:
name:
- firewalld
- fail2ban
state: present
when: ansible_os_family == "RedHat"
- name: Configure UFW firewall
ansible.builtin.ufw:
rule: allow
port: "{{ item.port }}"
proto: "{{ item.proto }}"
loop:
- { port: '22', proto: tcp }
- { port: '80', proto: tcp }
- { port: '443', proto: tcp }
when:
- enable_ufw | bool
- ansible_os_family == "Debian"
- name: Enable UFW
ansible.builtin.ufw:
state: enabled
policy: deny
when:
- enable_ufw | bool
- ansible_os_family == "Debian"
- name: Configure fail2ban
ansible.builtin.template:
src: jail.local.j2
dest: /etc/fail2ban/jail.local
mode: '0644'
notify: Restart fail2ban
when: enable_fail2ban | bool
- name: Configure fail2ban jail for nginx
ansible.builtin.lineinfile:
path: /etc/fail2ban/jail.local
line: |
[nginx-limit-req]
enabled = true
port = http,https
filter = nginx-limit-req
logpath = /var/log/nginx/*error.log
maxretry = 5
bantime = 3600
findtime = 600
when: enable_fail2ban | bool
- name: Enable and start fail2ban
ansible.builtin.systemd:
name: fail2ban
state: started
enabled: true
when: enable_fail2ban | bool
- name: Configure automatic security updates
ansible.builtin.template:
src: 50unattended-upgrades.j2
dest: /etc/apt/apt.conf.d/50unattended-upgrades
mode: '0644'
when:
- enable_automatic_updates | bool
- ansible_os_family == "Debian"
- name: Enable automatic updates
ansible.builtin.template:
src: 20auto-upgrades.j2
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: '0644'
when:
- enable_automatic_updates | bool
- ansible_os_family == "Debian"
- name: Configure sysctl hardening
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
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.secure_redirects', value: '0' }
- { name: 'net.ipv4.conf.default.secure_redirects', value: '0' }
- { name: 'net.ipv4.conf.all.log_martians', value: '1' }
- { name: 'net.ipv4.conf.default.log_martians', value: '1' }
- { name: 'net.ipv4.icmp_echo_ignore_broadcasts', value: '1' }
- { name: 'net.ipv4.icmp_ignore_bogus_error_responses', value: '1' }
- { name: 'net.ipv4.conf.all.rp_filter', value: '1' }
- { name: 'net.ipv4.conf.default.rp_filter', value: '1' }
- { name: 'net.ipv4.tcp_syncookies', value: '1' }
- name: Create update script
ansible.builtin.copy:
content: |
#!/bin/bash
apt-get update && apt-get upgrade -y
apt-get autoremove -y
apt-get autoclean
dest: /usr/local/bin/system-update.sh
mode: '0755'
when: ansible_os_family == "Debian"
---
- name: Restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
---
- name: Install Docker prerequisites
ansible.builtin.apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
- python3-pip
state: present
update_cache: true
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
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Install Docker (RHEL)
ansible.builtin.dnf:
name:
- docker
- docker-compose-plugin
state: present
when: ansible_os_family == "RedHat"
- name: Enable and start Docker
ansible.builtin.systemd:
name: docker
state: started
enabled: true
- name: Configure Docker daemon
ansible.builtin.template:
src: daemon.json.j2
dest: /etc/docker/daemon.json
mode: '0644'
notify: Restart docker
- name: Create Docker configuration directory
ansible.builtin.file:
path: /etc/docker/certs.d
state: directory
mode: '0755'
- name: Add user to docker group
ansible.builtin.user:
name: "{{ ansible_user }}"
groups: docker
append: true
---
- name: Create application directory
ansible.builtin.file:
path: "{{ postiz_app_root }}"
state: directory
mode: '0755'
owner: root
group: root
- name: Create data directories
ansible.builtin.file:
path: "{{ postiz_app_root }}/{{ item }}"
state: directory
mode: '0755'
loop:
- backups
- logs
- uploads
- name: Clone Postiz repository
ansible.builtin.git:
repo: https://github.com/gitroomhq/postiz-app
dest: "{{ postiz_app_root }}/postiz-app"
version: "{{ postiz_version }}"
force: true
register: git_clone_result
- name: Generate passwords if not provided
ansible.builtin.set_fact:
postgres_password: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters,digits') }}"
redis_password: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters,digits') }}"
jwt_secret: "{{ lookup('password', '/dev/null length=48 chars=ascii_letters,digits') }}"
when:
- postgres_password is not defined or postgres_password == 'CHANGE_ME_STRONG_PASSWORD'
- redis_password is not defined or redis_password == 'CHANGE_ME_STRONG_PASSWORD'
- jwt_secret is not defined or jwt_secret == 'CHANGE_ME_JWT_SECRET_MINIMUM_32_CHARS'
- name: Create .env file
ansible.builtin.template:
src: .env.j2
dest: "{{ postiz_app_root }}/.env"
mode: '0600'
vars:
generated_postgres_password: "{{ postgres_password }}"
generated_redis_password: "{{ redis_password }}"
generated_jwt_secret: "{{ jwt_secret }}"
- name: Create Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ postiz_app_root }}/docker-compose.yml"
mode: '0644'
- name: Create backup script
ansible.builtin.template:
src: backup.sh.j2
dest: "{{ postiz_app_root }}/backup.sh"
mode: '0755'
- name: Create systemd service for Postiz
ansible.builtin.template:
src: postiz.service.j2
dest: /etc/systemd/system/postiz.service
mode: '0644'
notify:
- Reload systemd
- Restart postiz
- name: Start Postiz services
community.docker.docker_compose:
project_src: "{{ postiz_app_root }}"
state: present
files:
- docker-compose.yml
- name: Wait for Postiz to be ready
ansible.builtin.uri:
url: "http://localhost:3000/health"
method: GET
status_code: 200
register: postiz_health
retries: 10
delay: 10
until: postiz_health.status == 200
ignore_errors: true
services:
app:
image: ghcr.io/gitroomhq/postiz-app:{{ postiz_version }}
container_name: postiz-app
restart: unless-stopped
environment:
- NODE_ENV=production
- APP_URL=https://{{ postiz_domain }}
- DATABASE_URL=postgresql://{{ postgres_user }}:{{ postgres_password }}@postgres:5432/{{ postgres_db }}
- REDIS_URL=redis://:{{ redis_password }}@redis:6379
- JWT_SECRET={{ jwt_secret }}
{% if resend_api_key %}
- RESEND_API_KEY={{ resend_api_key }}
{% endif %}
{% if from_email %}
- FROM_EMAIL={{ from_email }}
{% endif %}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- postiz-network
volumes:
- app-uploads:/app/uploads
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:size=100M,mode=1777
cap_drop:
- ALL
user: "1000:1000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
worker:
image: ghcr.io/gitroomhq/postiz-app:{{ postiz_version }}
container_name: postiz-worker
restart: unless-stopped
command: ["node", "dist/apps/workers/main.js"]
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://{{ postgres_user }}:{{ postgres_password }}@postgres:5432/{{ postgres_db }}
- REDIS_URL=redis://:{{ redis_password }}@redis:6379
- JWT_SECRET={{ jwt_secret }}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- postiz-network
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:size=100M,mode=1777
cap_drop:
- ALL
user: "1000:1000"
postgres:
image: postgres:15-alpine
container_name: postiz-postgres
restart: unless-stopped
environment:
- POSTGRES_USER={{ postgres_user }}
- POSTGRES_PASSWORD={{ postgres_password }}
- POSTGRES_DB={{ postgres_db }}
volumes:
- postgres_data:/var/lib/postgresql/data
- {{ postiz_app_root }}/backups:/backups
networks:
- postiz-network
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ postgres_user }} -d {{ postgres_db }}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: postiz-redis
restart: unless-stopped
command: redis-server --requirepass {{ redis_password }} --appendonly yes
volumes:
- redis_data:/data
networks:
- postiz-network
security_opt:
- no-new-privileges:true
read_only: true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "redis-cli", "-a", "{{ redis_password }}", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
postiz-network:
driver: bridge
volumes:
postgres_data:
driver: local
redis_data:
driver: local
app-uploads:
driver: local
# Postiz Environment Configuration
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
# Application
APP_ENV=production
NODE_ENV=production
APP_URL=https://{{ postiz_domain }}
# Database
POSTGRES_USER={{ postgres_user }}
POSTGRES_PASSWORD={{ postgres_password }}
POSTGRES_DB={{ postgres_db }}
# Redis
REDIS_PASSWORD={{ redis_password }}
# Security
JWT_SECRET={{ jwt_secret }}
# Email Notifications
{% if resend_api_key %}
RESEND_API_KEY={{ resend_api_key }}
{% endif %}
{% if from_email %}
FROM_EMAIL={{ from_email }}
{% endif %}
# Backup
BACKUP_DIR={{ postiz_app_root }}/backups
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart postiz
community.docker.docker_compose:
project_src: "{{ postiz_app_root }}"
state: present
restarted: true
- name: Restart nginx
ansible.builtin.systemd:
name: nginx
state: restarted
Create your inventory.ini file:
[postiz_servers]
postiz.example.com ansible_user=deploy
[postiz_servers:vars]
postiz_domain=postiz.example.com
postiz_email=admin@example.com
postgres_password=YourStrongPassword32Chars
redis_password=YourStrongPassword32Chars
jwt_secret=YourJwtSecretMinimum32CharactersLong
ansible all -i inventory.ini -m ping
Dry run (check mode):
ansible-playbook -i inventory.ini postiz-deploy.yml --check
Full deployment:
ansible-playbook -i inventory.ini postiz-deploy.yml
With verbose output:
ansible-playbook -i inventory.ini postiz-deploy.yml -vvv
Limit to specific tags:
# Only Docker installation
ansible-playbook -i inventory.ini postiz-deploy.yml --tags docker
# Only security hardening
ansible-playbook -i inventory.ini postiz-deploy.yml --tags security
# Only Postiz deployment
ansible-playbook -i inventory.ini postiz-deploy.yml --tags postiz
# SSH to server
ssh deploy@postiz.example.com
# Check Docker containers
docker compose ps
# View logs
docker compose logs -f app
# Check health
curl http://localhost:3000/health
https://postiz.example.comCreate monitoring script:
sudo nano /opt/postiz/health-check.sh
#!/bin/bash
HEALTH=$(curl -s http://localhost:3000/health)
if [ $? -eq 0 ]; then
echo "Postiz is healthy"
exit 0
else
echo "Postiz health check failed"
exit 1
fi
Add to crontab:
*/5 * * * * /opt/postiz/health-check.sh
Test backup script:
/opt/postiz/backup.sh
Verify backup files:
ls -lh /opt/postiz/backups/
SSH connection fails:
ansible all -i inventory.ini -m ping -vvv
Docker installation fails:
ansible-playbook -i inventory.ini postiz-deploy.yml --tags docker -vvv
Postiz container wonβt start:
ssh deploy@postiz.example.com
docker compose logs app
docker compose down && docker compose up -d
Database connection errors:
docker compose exec postgres pg_isready
docker compose logs postgres
Rollback to previous version:
cd /opt/postiz/postiz-app
git checkout <previous-version>
docker compose down
docker compose up -d
Any questions?
Feel free to contact us. Find all contact information on our contact page.