This guide provides a security-hardened, auditable method for deploying SafeLine WAF using Docker Compose. Unlike standard automated installation scripts, this procedure eliminates unverified remote code execution (curl | bash), enforces SSL/TLS integrity, isolates privileges, and hardens environment secrets.
I run reverse proxies and homelab apps in containers (see Jellyfin on Alpine for a different stack). SafeLine is a WAF in front of those apps, not a replacement for patching them. This post is the install I actually want on a VPS: read the compose file, generate a real DB password, bind the admin UI to you rather than the world.
Contact if you are following along from the channel: thelinuxitguy@gmail.com — but do not email me your .env. Rotate secrets if you pasted them in chat.
Architecture & security principles
- Zero uninspected execution: No remote shell scripts are executed directly in
bash. - Package manager integrity: Docker is installed using official GPG keys and signed Linux package repositories.
- Least privilege & isolation: Operations are strictly audited, and services run inside isolated Docker container stacks.
- Credential hardening: Database and management secrets are generated using cryptographic randomness.
- Network boundaries: Management interfaces are restricted behind local firewalls or VPNs.
SafeLine’s marketing install is often “pipe this to bash.” That is convenient and also the same pattern malware uses. We will fetch compose.yaml over HTTPS, read it, then docker compose up. If the YAML grows a surprise privileged container next release, you will see it in less before it runs.
Prerequisites
- A Debian/Ubuntu server you control (the Docker APT steps below are Ubuntu/Debian). Fedora/RHEL can use the official Docker repo for those families instead — do not mix
aptinstructions on Rocky. - Root or sudo, and a user you can add to the
dockergroup if you insist on rootless-ish workflow. I still usesudo docker composehere so the engine socket is not a second root. - Ports: 9443 (management UI), plus 80/443 if this box is the public reverse path. Do not put 9443 on the internet.
openssl,curl,gpg, and a firewall (ufwin the examples).- Enough disk under
/data/safelinefor images, Postgres, and logs.
If this host already runs another reverse proxy on 80/443, stop and plan bindings. Two processes cannot own 443.
Step 1: Securely install Docker (official repository method)
Avoid using curl -sSL https://get.docker.com | bash. Instead, import Docker’s official GPG key and add the authenticated repository to your system package manager.
For Ubuntu / Debian systems
# Update local package indexes and install prerequisites
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
# Create directory for official keyrings
sudo install -m 0755 -d /etc/apt/keyrings
# Download Docker's official GPG key with standard TLS verification
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add the authenticated Docker repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Update and install Docker Engine and Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Verify Docker daemon status
sudo systemctl is-active docker
Expected output: active. docker compose version should print a v2 plugin version. If VERSION_CODENAME is not a Ubuntu release Docker supports, this is Debian — use Docker’s Debian instructions (linux/debian and the Debian codename) rather than forcing Ubuntu URLs.
Enable on boot:
sudo systemctl enable --now docker
sudo docker run --rm hello-world
hello-world proves pull + run over HTTPS. If it fails, fix registry access before SafeLine’s larger pull.
Debian users: swap the linux/ubuntu paths for linux/debian as documented on docs.docker.com. Podman Compose can work but SafeLine’s YAML is written for Docker; I do not pretend it is drop-in on CoreOS here. CoreOS stays on Podman Quadlets for DNS (AdGuard); WAF on a mutable VPS is a different animal.
Step 2: Prepare a dedicated application workspace
Create an isolated directory with proper file permissions to host SafeLine configuration data.
# Create application root directory
sudo mkdir -p /data/safeline
# Make your user the owner of the folder
sudo chown -R $USER:root /data/safeline
# Set ownership to root with strict permissions
sudo chmod 750 /data/safeline
cd /data/safeline
Why /data/safeline? It is obvious, not mixed with /home downloads, and easy to snapshot. chmod 750 keeps other local users out of compose and .env. If you chown to $USER:root then later only run compose as root, that is fine; the point is the directory is not world-readable.
Step 3: Fetch and audit the SafeLine Docker Compose artifacts
Download the Compose manifest enforcing strict TLS/SSL verification, and inspect the configuration prior to execution.
# Download the compose configuration using enforced TLS verification
sudo curl -fsSL "https://waf.chaitin.com/release/latest/compose.yaml" -o /data/safeline/compose.yaml
# Inspect the file contents for unexpected images, volumes, or network privileges
less /data/safeline/compose.yaml
Audit checklist during inspection:
- Ensure container images originate from trusted registries (e.g.,
chaitin/*).- Verify that exposed ports match expected services (e.g., Management Port
9443, Web Ports80/443).- Ensure persistent host volumes point only to
/data/safeline/.- Look for
privileged: true, Docker socket mounts (/var/run/docker.sock), andnetwork_mode: host. Those are not automatically evil, but they are not “just a WAF” either. Stop and read if you see the socket.
curl -fsSL fails on TLS errors instead of writing a half file. If the URL 404s, the vendor moved the path — fetch from their current docs, still less before up.
Take a copy you can diff later:
sudo cp /data/safeline/compose.yaml /data/safeline/compose.yaml.$(date +%F)
Step 4: Configure environment & cryptographic secrets
Do not use default or weak passwords. Generate a cryptographically secure random password for the underlying PostgreSQL database.
# Navigate to deployment directory
cd /data/safeline
# Generate a 32-character high-entropy secret for PostgreSQL
POSTGRES_SECURE_PASS=$(openssl rand -hex 16)
# Create the environment configuration file with secure permissions
sudo touch .env
sudo chmod 600 .env
# Populate .env configuration
sudo tee .env > /dev/null <<EOF
SAFELINE_DIR=/data/safeline
IMAGE_TAG=latest
MGT_PORT=9443
POSTGRES_PASSWORD=${POSTGRES_SECURE_PASS}
SUBNET_PREFIX=172.22.222
IMAGE_PREFIX=chaitin
ARCH_SUFFIX=
RELEASE=
REGION=-g
MGT_PROXY=0
EOF
Expected: sudo cat /data/safeline/.env shows a hex password, mode 600. The tee heredoc expanded POSTGRES_SECURE_PASS in your shell — good. If you run that block in a way that leaves the variable empty, compose will start Postgres with an empty password. Check:
sudo grep POSTGRES_PASSWORD /data/safeline/.env
You should see a long hex string, not blank.
IMAGE_TAG=latest is convenient. For a VPS you care about, pin a tag after you have a known-good deploy and write it down next to the backup. SUBNET_PREFIX must not collide with an existing Docker network or your LAN (172.22.222.0/24 style). If docker compose up complains about overlapping pools, pick another prefix.
Vendor .env keys change. If docker compose config errors on unknown variables, compare with comments in the YAML you just audited — do not invent keys.
Step 5: Validate and launch services
Verify the Docker Compose layout and pull the image containers securely.
cd /data/safeline
# Verify environment variable substitution and syntax
sudo docker compose config
# Pull container images over HTTPS with signature verification
sudo docker compose pull
# Launch SafeLine in detached mode
sudo docker compose up -d
Expected config: a rendered YAML with the password substituted (do not paste that output into a ticket). Expected up: containers created, healthy or starting. Pull can take a while; that is normal.
If config prints the default password from their sample, your .env was not read (--env-file / working directory). Always cd /data/safeline first.
Step 6: Verify service health & initial setup
# Monitor container status
sudo docker compose ps
# View initialization logs to ensure DB setup and service startup succeed without errors
sudo docker compose logs -f --tail=50
Once running, access the SafeLine management console at:
https://<YOUR-SERVER-IP>:9443
The UI will be HTTPS with a likely self-signed cert. That is expected on a homelab IP. Do not click through that warning on a cafe Wi-Fi to a public IP — use SSH tunnel or VPN:
ssh -L 9443:127.0.0.1:9443 user@your-server
Then open https://127.0.0.1:9443 locally. Combine with the firewall rules below so 9443 is not world-open while you still learn the product.
Expected ps: management, postgres, and related services Up. If postgres restart-loops, the password changed after the volume was initialized — do not keep editing .env hoping; that volume already has the old password. Restore from backup or wipe only if you accept losing WAF config.
Step 7: Post-installation hardening & maintenance
- Firewall rules (
ufw/iptables):
Restrict access to port9443(Management UI) so it is only accessible via trusted admin IPs or a management VPN (e.g., Tailscale / WireGuard).
sudo ufw allow from <YOUR_ADMIN_IP> to any port 9443 proto tcp
sudo ufw deny 9443/tcp
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
Replace <YOUR_ADMIN_IP>. If you use Tailscale, allow the tailnet prefix instead of your home WAN IP (WAN IPs change). Order matters in ufw: more specific allow should exist; deny 9443 then a later allow can still work depending on numbering — ufw status numbered and test from a non-admin IP.
If this host is not the public 80/443 edge, do not allow those ports.
- Obtain initial admin credentials:
Retrieve the initial administrator credentials generated during container boot:
sudo docker exec -it safeline-mgt resetadmin
The container name may differ; sudo docker compose ps is authoritative. If resetadmin is not a command in your image tag, use the vendor’s current reset documented next to that compose file — do not guess SQL against Postgres.
Change the admin password after first login. Default credentials in a blog post are how WAFs become public joke dashboards.
-
Backup strategy:
Periodically back up/data/safeline/(specifically your.envfile, database volumes, and SSL certificates). A compose project is not backed up because you ranuponce. Snapshot/data/safelineafter stopping compose if you need a consistent Postgres filesystem copy, or usepg_dumpinside the DB container if you know the schema. Test a restore on a spare VM before you need it. -
Updating SafeLine securely:
To update in the future without automated piping:
cd /data/safeline
sudo cp compose.yaml compose.yaml.bak
sudo curl -fsSL "https://waf.chaitin.com/release/latest/compose.yaml" -o compose.yaml
diff -u compose.yaml.bak compose.yaml
sudo docker compose pull
sudo docker compose up -d
Read the diff. That is the whole point of refusing curl | bash.
Troubleshooting
docker compose: command not found: you installed classicdocker-composeor nothing. This guide uses the v2 plugin (docker compose).- Permission denied on docker.sock: you ran without sudo and your user is not in
docker. Use sudo ornewgrp dockerafter a correct usermod — knowing thatdockergroup is root-equivalent. - Cannot reach 9443: firewall, wrong
MGT_PORT, or compose bind is127.0.0.1only.sudo ss -tulpn | grep 9443. - Let’s Encrypt / public site behind WAF: point DNS at this host only after 80/443 are the WAF, not a leftover nginx. Split-brain proxies are the usual “it works on curl from localhost.”
- Resource usage: a WAF + Postgres is not a Pi Zero project. If
compose psis healthy but the site times out, look at CPU and disk before rewriting YAML.
Wrap-up
SafeLine on Docker Compose does not require a curl-piped installer. Install Docker from the signed apt repo, download compose.yaml, read it, put a random Postgres password in a 600 .env, bring the stack up, and keep 9443 off the public internet. Updates are the same curl + diff + pull loop.
That is the standard I want for anything that sits in front of real traffic. The WAF is not a substitute for patched apps, and it is not an excuse to skip nftables/ufw.
Best regards,
The Linux IT Guy