Comprehensive security and hardening guidance for self-hosted Postiz deployments. This guide covers OS hardening, container security, network isolation, credential management, and operational security best practices.
Postiz handles sensitive data including:
| Priority | Focus Area | Risk Level |
|---|---|---|
| 1 | OAuth Token Protection | 🔴 Critical |
| 2 | Container Security | 🔴 Critical |
| 3 | Network Isolation | 🟠 High |
| 4 | Credential Management | 🟠 High |
| 5 | Access Control | 🟡 Medium |
| 6 | Audit Logging | 🟡 Medium |
| Component | Responsibility |
|---|---|
| Postiz Application | Upstream developers |
| Container Configuration | Your team |
| OS Hardening | Your team |
| Network Security | Your team |
| Credential Management | Your team |
| Data Backups | Your team |
GHSA-48c8-25jq-m55f
| Attribute | Details |
|---|---|
| Severity | High |
| Affected Versions | 1.45.1 through 1.62.3 |
| Fixed Version | 1.62.3+ |
| Type | Server-Side Request Forgery (SSRF) |
| CVE ID | CVE-2025-53641 |
Immediate Actions Required:
Upgrade Immediately:
docker compose pull
docker compose up -d
Verify Version:
docker compose exec app cat version.txt
# Should show v2.18.0 or newer
Check for Exploitation:
docker compose logs app | grep -i "ssrf\|request\|fetch"
Rotate Tokens if Suspicion Exists:
Postiz security policy: https://github.com/gitroomhq/postiz-app/security
Reporting Vulnerabilities:
Enable automatic security updates:
Debian/Ubuntu:
sudo apt-get install -y unattended-upgrades apt-listchanges
# Configure automatic updates
sudo dpkg-reconfigure --priority=low unattended-upgrades
Create update script:
sudo nano /usr/local/bin/system-update.sh
#!/bin/bash
apt-get update
apt-get upgrade -y
apt-get autoremove -y
apt-get autoclean
RHEL/CentOS:
sudo dnf install -y dnf-automatic
sudo systemctl enable --now dnf-automatic-install.timer
Configure sysctl parameters:
sudo nano /etc/sysctl.d/99-postiz-hardening.conf
# Network Security
net.ipv4.ip_forward = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.tcp_syncookies = 1
# IPv6 Security (if not using IPv6)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
# Kernel Protection
kernel.randomize_va_space = 2
kernel.exec-shield = 1
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
kernel.unprivileged_bpf_disabled = 1
kernel.unprivileged_userns_clone = 0
Apply settings:
sudo sysctl --system
Create dedicated user:
sudo useradd -r -s /bin/false -d /opt/postiz postiz
Remove unnecessary users:
sudo passwd -l games
sudo passwd -l news
Configure sudo:
sudo visudo
# Restrict sudo access
%admin ALL=(ALL:ALL) ALL
# Require password for sudo
Defaults timestamp_timeout=5
Set proper permissions:
# Application directory
sudo chown -R root:root /opt/postiz
sudo chmod -R 755 /opt/postiz
# Environment file
sudo chmod 600 /opt/postiz/.env
# Backup directory
sudo chmod 700 /opt/postiz/backups
Mount options:
# Add to /etc/fstab
/dev/sda1 /opt ext4 defaults,noexec,nosuid,nodev 0 0
Configure Docker daemon:
sudo nano /etc/docker/daemon.json
{
"storage-driver": "overlay2",
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"userns-remap": "default",
"live-restore": true,
"no-new-privileges": true,
"icc": false,
"userland-proxy": false
}
Restart Docker:
sudo systemctl restart docker
Production Docker Compose security settings:
services:
app:
image: ghcr.io/gitroomhq/postiz-app:v2.18.0
security_opt:
- no-new-privileges:true
- apparmor=docker-default
read_only: true
tmpfs:
- /tmp:size=100M,mode=1777
- /run:size=50M,mode=1777
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
user: "1000:1000"
userns_mode: "host"
pid: "no"
privileged: false
stdin_open: false
tty: false
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
| Setting | Purpose | Risk if Missing |
|---|---|---|
no-new-privileges |
Prevent privilege escalation | 🔴 High |
read_only |
Prevent filesystem modifications | 🟠 High |
cap_drop: ALL |
Remove all capabilities | 🔴 Critical |
user |
Run as non-root | 🔴 Critical |
tmpfs |
Temporary writable directories | 🟡 Medium |
| Resource limits | Prevent DoS | 🟠 High |
privileged: false |
No privileged container | 🔴 Critical |
Run Docker security audit:
# Install Docker Bench
docker run -it --net host --pid host --userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /etc:/etc:ro \
-v /var/lib/docker:/var/lib/docker:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /usr/bin/containerd:/usr/bin/containerd \
-v /usr/bin/runc:/usr/bin/runc \
docker/docker-bench-security
Review results:
docker logs docker-bench-security
Use specific version tags:
# Good - specific version
image: ghcr.io/gitroomhq/postiz-app:v2.18.0
# Bad - mutable tag
image: ghcr.io/gitroomhq/postiz-app:latest
Enable Docker Content Trust:
export DOCKER_CONTENT_TRUST=1
Scan images for vulnerabilities:
# Install Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image ghcr.io/gitroomhq/postiz-app:v2.18.0
Create custom AppArmor profile:
sudo nano /etc/apparmor.d/docker-postiz
#include <tunables/global>
profile docker-postiz 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,
deny /bin/** mlx,
deny /sbin/** mlx,
deny /usr/bin/** mlx,
deny /usr/sbin/** mlx,
/app/** rw,
/tmp/** rw,
capability chown,
capability setuid,
capability setgid,
}
Apply profile:
sudo apparmor_parser -r /etc/apparmor.d/docker-postiz
UFW (Ubuntu/Debian):
# Default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH
sudo ufw allow 22/tcp
# Allow HTTP/HTTPS (for Let's Encrypt)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enable firewall
sudo ufw enable
# Check status
sudo ufw status verbose
firewalld (RHEL/CentOS):
# 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=https
sudo firewall-cmd --permanent --add-service=http
# Remove unnecessary services
sudo firewall-cmd --permanent --remove-service=dhcpv6-client
# Enable and reload
sudo firewall-cmd --reload
sudo systemctl enable firewalld
iptables rules:
# Allow established connections
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow SSH
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
# Allow HTTP/HTTPS
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
# Drop everything else
iptables -A INPUT -j DROP
Secure Nginx configuration:
server {
listen 443 ssl http2;
server_name postiz.example.com;
# TLS Configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# 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' https:; frame-ancestors 'self';" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Rate Limiting
limit_req_zone $binary_remote_addr zone=postiz_limit:10m rate=10r/s;
limit_req zone=postiz_limit burst=20 nodelay;
# Logging
access_log /var/log/nginx/postiz_access.log;
error_log /var/log/nginx/postiz_error.log;
# Client limits
client_max_body_size 50M;
client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 10s;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# Block sensitive paths
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Health check (no rate limiting)
location /health {
proxy_pass http://127.0.0.1:3000/health;
access_log off;
limit_req off;
}
}
# HTTP to HTTPS redirect
server {
listen 80;
server_name postiz.example.com;
return 301 https://$server_name$request_uri;
}
Create isolated network:
networks:
postiz-network:
driver: bridge
ipam:
driver: default
config:
- subnet: 172.28.0.0/16
internal: false # Set true for complete isolation
Internal network for database:
networks:
internal-network:
driver: bridge
internal: true
ipam:
config:
- subnet: 172.29.0.0/16
Assign services:
services:
postgres:
networks:
- internal-network
redis:
networks:
- internal-network
app:
networks:
- postiz-network
- internal-network
Create secrets:
# Generate and create secrets
echo "your-jwt-secret-48-chars" | docker secret create jwt_secret -
echo "your-postgres-password" | docker secret create postgres_password -
echo "your-redis-password" | docker secret create redis_password -
Use in Docker Compose:
services:
app:
secrets:
- jwt_secret
- postgres_password
- redis_password
environment:
- JWT_SECRET_FILE=/run/secrets/jwt_secret
- DATABASE_URL=postgresql://postiz:$(cat /run/secrets/postgres_password)@postgres:5432/postiz
secrets:
jwt_secret:
external: true
postgres_password:
external: true
redis_password:
external: true
Configure Vault:
# Install Vault
docker run -d --cap-add=IPC_LOCK -e 'VAULT_DEV_ROOT_TOKEN_ID=myroot' -p 8200:8200 vault
# Enable secrets engine
vault secrets enable -path=secret kv-v2
# Store secrets
vault kv put secret/postiz jwt_secret="xxx" postgres_password="xxx"
Retrieve in application:
vault kv get -field=jwt_secret secret/postiz
Rotation schedule:
| Credential | Rotation Frequency | Priority |
|---|---|---|
| JWT Secret | 90 days | 🔴 Critical |
| Database Password | 90 days | 🔴 Critical |
| Redis Password | 90 days | 🟠 High |
| OAuth Tokens | On expiry | 🟠 High |
| API Keys | 180 days | 🟡 Medium |
Rotation procedure:
User roles in Postiz:
| Role | Permissions | Use Case |
|---|---|---|
| Admin | Full access | System administrators |
| Manager | Content + team management | Team leads |
| Creator | Create and schedule content | Content creators |
| Publisher | Approve and publish | Editors |
| Viewer | Read-only access | Stakeholders |
Configure SSH:
sudo nano /etc/ssh/sshd_config
# Disable root login
PermitRootLogin no
# Disable password authentication
PasswordAuthentication no
PubkeyAuthentication yes
# Use strong authentication
AuthenticationMethods publickey
# Limit users
AllowUsers deploy admin
# Change port (optional)
Port 2222
# Disable X11 forwarding
X11Forwarding no
# Limit attempts
MaxAuthTries 3
LoginGraceTime 60
# Enable logging
LogLevel VERBOSE
Restart SSH:
sudo systemctl restart sshd
Rate limiting:
RATE_LIMIT_TTL=60
RATE_LIMIT_MAX=100
API token management:
Install and configure:
sudo apt-get install -y fail2ban
sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
[sshd]
enabled = true
port = ssh
logpath = /var/log/auth.log
[nginx-limit-req]
enabled = true
port = http,https
filter = nginx-limit-req
logpath = /var/log/nginx/*error.log
maxretry = 5
bantime = 3600
[postiz-auth]
enabled = true
filter = postiz-auth
logpath = /var/log/postiz/auth.log
maxretry = 5
bantime = 7200
Create Postiz filter:
sudo nano /etc/fail2ban/filter.d/postiz-auth.conf
[Definition]
failregex = ^.*Failed login attempt.*<HOST>.*$
^.*Invalid credentials.*<HOST>.*$
ignoreregex =
Configure rsyslog:
sudo nano /etc/rsyslog.d/postiz.conf
# Postiz logs
:programname, isequal, "docker" /var/log/postiz/docker.log
:programname, isequal, "postiz" /var/log/postiz/app.log
& stop
Forward to central syslog:
*.* @syslog.example.com:514
Monitor for:
Create monitoring script:
sudo nano /opt/postiz/security-monitor.sh
#!/bin/bash
# Check for failed logins
FAILED=$(grep "Failed" /var/log/auth.log | wc -l)
if [ $FAILED -gt 10 ]; then
echo "ALERT: $FAILED failed login attempts" | mail -s "Security Alert" admin@example.com
fi
# Check container health
UNHEALTHY=$(docker ps --filter "health=unhealthy" | wc -l)
if [ $UNHEALTHY -gt 0 ]; then
echo "ALERT: $UNHEALTHY unhealthy containers" | mail -s "Container Alert" admin@example.com
fi
# Check disk space
USAGE=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
if [ $USAGE -gt 80 ]; then
echo "ALERT: Disk usage at ${USAGE}%" | mail -s "Disk Alert" admin@example.com
fi
Add to crontab:
*/15 * * * * /opt/postiz/security-monitor.sh
Encrypt with GPG:
# Generate GPG key
gpg --gen-key
# Encrypt backup
gpg --encrypt --recipient admin@example.com --output backup.sql.gz.gpg backup.sql.gz
# Decrypt backup
gpg --decrypt --output backup.sql.gz backup.sql.gz.gpg
Create checksum:
sha256sum backup.sql.gz > backup.sql.gz.sha256
Verify backup:
sha256sum -c backup.sql.gz.sha256
Backup locations:
Backup permissions:
chmod 600 /opt/postiz/backups/*.gpg
chown root:root /opt/postiz/backups
1. Identify:
2. Contain:
3. Eradicate:
4. Recover:
5. Document:
| Role | Contact | Availability |
|---|---|---|
| Security Lead | security@example.com | 24/7 |
| System Admin | admin@example.com | Business hours |
| On-Call | oncall@example.com | 24/7 |
| Data Type | Retention Period | Deletion Method |
|---|---|---|
| User Data | Account lifetime | Secure delete |
| Logs | 90 days | Rotate and purge |
| Backups | 30 days | Overwrite |
| Analytics | 1 year | Aggregate |
Any questions?
Feel free to contact us. Find all contact information on our contact page.