Turn a fresh Ubuntu VPS into a production-hardened Coolify server in ~15 minutes.
Coolify is an open-source self-hosting platform — deploy apps, databases, and services with a UI (like a self-hosted Heroku). This project secures a Coolify server from scratch.
What you start with: A fresh Ubuntu 24.04 VPS with root access.
What you end with:
- ✅ Coolify running and accessible at
https://your-domain.com - ✅ SSH + dashboard only accessible via Tailscale VPN (no public attack surface)
- ✅ Automatic SSL for all apps via Cloudflare
- ✅ Hardened kernel, firewall, audit logging, and auto-updates
Deploying Coolify on a fresh VPS leaves significant security gaps: root SSH enabled, default firewall rules, no audit logging, and the Coolify dashboard exposed to the internet. This project closes those gaps with a defense-in-depth approach:
| Problem | Solution |
|---|---|
| Root SSH + password auth | Key-only SSH, admin user, root login disabled |
| No firewall policy | UFW default-deny, DOCKER-USER chain rules |
| Dashboard publicly accessible | Bind to Tailscale VPN IP only |
| No intrusion detection | Auditd rules for privileged operations, fail2ban |
| Kernel defaults | SYN cookies, ASLR, ptrace restrictions, BBR |
| Manual security patches | Unattended-upgrades with scheduled reboots |
Result: A hardened server where the only way to SSH or access the Coolify dashboard is through your Tailscale VPN — zero public attack surface on management interfaces.
Internet
│
┌──────┴──────┐
│ Cloudflare │ ← Universal SSL (*.example.com)
│ Edge │
└──────┬──────┘
│
┌──────────┼──────────┐
│ Tunnel │ Standard │
│ (default)│ │
▼ │ ▼
outbound-only │ ports 80/443
connection │ (firewalled)
│ │ │
└──────────┼──────────┘
│
┌──────┴──────┐
│ Server │
│ ┌─────────┐ │
│ │ Traefik │ │ ← Host-header routing
│ │ (Coolify)│ │
│ └─────────┘ │
│ │
│ Tailscale ◄──┼── Admin SSH + Dashboard (100.x.x.x)
│ (VPN) │
└──────────────┘
No public SSH
No public dashboard
The deployment runs in 5 phases:
Phase 1: Upload scripts & harden server (SSH as root → public IP)
↓
Phase 2: Gate checks (verify hardening passed, get Tailscale IP)
↓
Phase 3: Install Docker & Coolify
↓
Phase 4: Configure Cloudflare DNS/Tunnel + wildcard subdomain
↓
Phase 5: Bind dashboard to Tailscale IP + final verification
Which script should I use?
| Script | When to use | What it does |
|---|---|---|
deploy.sh |
From your laptop, fresh VPS | Full automation: SSH in, harden, install Coolify, configure DNS |
setup.sh |
Already SSH'd into server | Same as deploy.sh but runs locally (no root password needed) |
bootstrap_hardening.sh |
You only want hardening, no Coolify | Just the 15 security controls, nothing else |
This project is designed to be LLM-friendly. If you're using Claude, GPT-5 Codex, or another AI assistant to help deploy:
- AGENTS_DEPLOY.md — Detailed instructions for AI agents to execute the deployment
- Clear phase structure — Each phase has explicit inputs, outputs, and verification gates
- Idempotent operations — Safe to re-run if interrupted or if the AI needs to retry
- Validation at every step —
validate_hardening.shprovides machine-readable JSON output
Just point your AI assistant to AGENTS_DEPLOY.md and it can guide you through the entire process.
Before you start, you need:
| Requirement | How to get it |
|---|---|
| Ubuntu 24.04 VPS | Any provider (Hetzner, DigitalOcean, Linode, etc.) — 2GB+ RAM, 40GB+ disk |
| Domain on Cloudflare | Move your domain to Cloudflare (free) |
| Tailscale account | Sign up at tailscale.com (free for personal use) |
| Tailscale auth key | Generate here — use "Reusable" and "Ephemeral" |
| Cloudflare API token | Create here with permissions: Zone:DNS:Edit + Account:Cloudflare Tunnel:Edit |
| SSH key pair | Run ssh-keygen -t ed25519 if you don't have one |
📦 Installing sshpass (macOS)
Required for deploy.sh only (automates SSH with root password):
brew install hudochenkov/sshpass/sshpassFrom your laptop — everything automated:
curl -fsSL https://raw.githubusercontent.com/YOUR_USERNAME/secure_coolify_ubuntu/main/deploy.sh | bash -s -- \
--server-ip <vps-ip> \
--root-pass <root-password> \
--tailscale-auth-key tskey-auth-... \
--domain app.example.com \
--cf-api-token <cloudflare-token> \
--yesgit clone https://github.com/YOUR_USERNAME/secure_coolify_ubuntu.git
cd secure_coolify_ubuntu
bash deploy.shAlready SSH'd into the server? Use setup.sh instead — same flags, runs locally (no --root-pass needed).
After deploy.sh or setup.sh completes, three manual steps enable automatic SSL + subdomains:
- Cloudflare: SSL/TLS > Overview — set encryption mode to Full (not Flexible, not Full Strict)
- Coolify: Servers > your server > Wildcard Domain — set to your zone root (e.g.,
example.com) - Coolify: resource domains — use
http://protocol, nothttps://
After this, every new app gets: auto-assigned subdomain → wildcard DNS → Cloudflare edge SSL → tunnel/proxy → Traefik → container. Zero per-app configuration.
| Tunnel (default) | Standard | |
|---|---|---|
| Flag | --tunnel-mode / --mode tunnel |
--mode standard |
| Inbound ports | None | 80, 443 |
| Traffic path | Outbound tunnel to Cloudflare edge | Direct to origin (Cloudflare-proxied) |
| Attack surface | Zero public HTTP/S | Origin IP exposed behind Cloudflare |
| Per-subdomain bypass | Not possible | DNS-only ("grey cloud") available |
Tunnel is the default because it eliminates direct-to-origin bypass entirely.
Evaluate these before choosing:
- 100MB upload limit — Cloudflare Free/Pro plans cap request bodies. Apps with large uploads (Nextcloud, Immich) need chunked upload support or standard mode.
- Nested subdomain TLS — Universal SSL covers
*.example.combut not*.app.example.com. Use single-level subdomains. - Media streaming — Heavy video streaming (Jellyfin, Plex) may violate Cloudflare CDN terms. Use standard mode with DNS-only for media subdomains.
- Cloudflare Access + webhooks — If you add Zero Trust auth later, create IP-based bypass policies for CI/CD webhook paths.
If these apply, use --mode standard.
Both modes use Cloudflare's edge for user-facing TLS. No wildcard certificate is needed on the origin.
| Mode | Edge TLS | Edge > Origin | Origin cert needed? |
|---|---|---|---|
| Tunnel | Universal SSL | Encrypted tunnel (no TLS check) | No |
| Standard (Full SSL) | Universal SSL | HTTPS, any cert accepted | Any (self-signed OK) |
Wildcard DNS (*.example.com) and tunnel ingress rules are created automatically.
bootstrap_hardening.sh applies 15 security controls. See HARDENING_PROCEDURE.md for full technical detail.
| # | Control | Key details |
|---|---|---|
| 1 | Preflight | OS validation, SSH session safety, interface detection |
| 2 | NTP | Time synchronization |
| 3 | Swap | Configurable (default 2G), OOM protection |
| 4 | Service cleanup | Disables rpcbind, avahi-daemon, cups |
| 5 | Login banner | Authorized access warning |
| 6 | SSH hardening | Key-only, modern ciphers, root login disabled |
| 7 | Auditd | Tracks identity changes, sudoers, Docker socket |
| 8 | Kernel hardening | SYN cookies, BBR, ASLR, ICMP hardening, ptrace restricted |
| 9 | UFW firewall | Default deny, Tailscale CIDR, tunnel-mode aware |
| 10 | Docker daemon | json-file log driver with rotation, live-restore |
| 11 | DOCKER-USER rules | IPv4/IPv6 chain hardening, bridge rules |
| 12 | Fail2ban | SSH jail with UFW ban action |
| 13 | Journald | Persistent logging with configurable retention |
| 14 | Auto-updates | Unattended security patches with scheduled reboots |
| 15 | Post-checks | Verification + JSON report |
Keeps secrets out of shell history:
cat > /etc/bootstrap-hardening.env << 'EOF'
ADMIN_USER=coolifyadmin
ADMIN_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host"
TUNNEL_MODE=true
SWAP_SIZE=4G
ENABLE_AUTO_REBOOT=true
AUTO_REBOOT_TIME=04:00
JOURNAL_RETENTION=3month
EOF
chmod 0600 /etc/bootstrap-hardening.env
sudo ./bootstrap_hardening.sh --env-file /etc/bootstrap-hardening.envInstall Tailscale and restrict Coolify dashboard to VPN only:
sudo ./bootstrap_hardening.sh \
--admin-user coolifyadmin \
--admin-pubkey "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host" \
--install-tailscale \
--tailscale-auth-key "tskey-auth-xxxxx" \
--bind-dashboard-to-tailscalePreview what would change without applying:
sudo ./bootstrap_hardening.sh \
--admin-user coolifyadmin \
--admin-pubkey "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host" \
--dry-run# Validate hardening (text output)
sudo ./validate_hardening.sh
# Validate hardening (JSON, for automation/CI)
sudo ./validate_hardening.sh --jsonUses BATS:
make setup-bats # Install BATS locally
make test-unit-local # Unit tests (fast, no Docker)
make test-all # Full suite with Docker| Make Target | Purpose |
|---|---|
test-ci-pr |
PR gate: unit + dry-run + validate + consistency |
test-ci-main |
Main branch: full suite |
test-dry-run |
Dry-run integration |
test-full-standard |
Full hardening, standard mode |
test-full-tunnel |
Full hardening, tunnel mode |
test-validate |
Validation script |
test-idempotency |
Re-run safety |
📋 deploy.sh flags
| Flag | Default | Description |
|---|---|---|
--server-ip <ip> |
(required) | Server public IPv4 |
--root-pass <pass> |
(required) | Root password for initial SSH |
--tailscale-auth-key <key> |
(required) | Tailscale auth key (tskey-auth-...) |
--domain <fqdn> |
(required) | Domain name for Coolify |
--cf-api-token <token> |
(required) | Cloudflare API token |
--admin-user <name> |
coolifyadmin |
Admin username |
--pubkey-file <path> |
~/.ssh/id_ed25519.pub |
SSH public key file |
--mode <tunnel|standard> |
tunnel |
Deployment mode |
--cf-zone <zone> |
derived from domain | Cloudflare zone |
--swap-size <size> |
2G |
Swap file size |
--yes |
false |
Skip confirmation prompts |
📋 bootstrap_hardening.sh flags
| Flag | Default | Description |
|---|---|---|
--admin-user <name> |
(required) | Admin username to create |
--admin-pubkey "<key>" |
(required) | SSH public key for admin |
--tunnel-mode |
false |
Skip WAN 80/443 (Cloudflare Tunnel) |
--swap-size <size> |
2G |
Swap size (0 to skip) |
--ssh-port <port> |
22 |
SSH port |
--tailscale-cidr <cidr> |
100.64.0.0/10 |
Tailscale network CIDR |
--wan-iface <iface> |
auto-detected | WAN interface |
--install-tailscale |
false |
Install Tailscale |
--tailscale-auth-key <key> |
— | Tailscale auth key (with --install-tailscale) |
--bind-dashboard-to-tailscale |
false |
Bind Coolify dashboard to Tailscale IP |
--enable-auto-reboot <bool> |
true |
Auto-reboot after security updates |
--auto-reboot-time <HH:MM> |
03:30 |
Reboot schedule |
--journal-retention <span> |
3month |
Journald retention period |
--upgrade-mail <address> |
— | Email for upgrade failure reports |
--env-file <path> |
— | Load options from file |
--dry-run |
false |
Preview without changes |
--force |
false |
Override safety gates |
All flags have corresponding environment variables (e.g., ADMIN_USER, TUNNEL_MODE). CLI flags override env-file values.
secure_coolify_ubuntu/
├── deploy.sh # Laptop-side deployment orchestrator
├── setup.sh # Server-side deployment orchestrator
├── bootstrap_hardening.sh # Security hardening script (15 controls)
├── validate_hardening.sh # Post-hardening verification
├── configure_coolify_binding.sh # Split-horizon dashboard binding
├── lib/
│ └── coolify-common.sh # Shared utilities (Cloudflare API, validation)
├── HARDENING_PROCEDURE.md # Detailed hardening technical reference
├── docs/
│ ├── DEPLOYMENT_RUNBOOK.md # Manual step-by-step deployment guide
│ └── testing.md # Test documentation
├── tests/
│ ├── unit/ # Fast unit tests
│ └── integration/ # Docker-based integration tests
├── Makefile # Test automation
└── scripts/
└── check_workflow_consistency.sh
🔍 Common Issues
Cause: You're trying to SSH to the public IP instead of the Tailscale IP.
Solution: Connect via Tailscale:
ssh admin@100.x.x.x # Use the Tailscale IP output by the scriptCause: Dashboard is bound to Tailscale IP only.
Solution:
- Ensure Tailscale is running on your laptop:
tailscale status - Access via
http://100.x.x.x:8000(Tailscale IP, not public IP)
Cause: API token missing required permissions.
Solution: Ensure token has both:
Zone:DNS:EditAccount:Cloudflare Tunnel:Edit
Run sudo ./validate_hardening.sh for details. Common fixes:
- UFW inactive:
sudo ufw enable - Auditd not running:
sudo systemctl enable --now auditd - Docker not installed: Hardening-only mode doesn't install Docker; use
deploy.shfor full setup
Contributions are welcome! Please read the following before submitting:
- Test your changes: Run
make test-ci-prbefore opening a PR - Follow the style: ShellCheck-clean, consistent formatting
- Document changes: Update relevant
.mdfiles
git clone https://github.com/YOUR_USERNAME/secure_coolify_ubuntu.git
cd secure_coolify_ubuntu
make setup-bats
make test-unit-localPlease do not report security vulnerabilities through public GitHub issues.
Instead, please use GitHub Security Advisories.
You should receive a response within 48 hours. If the vulnerability is confirmed:
- We'll work on a fix and coordinate disclosure with you
- Credit will be given in the advisory unless you prefer to remain anonymous
| Version | Supported |
|---|---|
| main | ✅ |
| < 1.0 | ❌ |
- Coolify — The self-hosting platform this project secures
- Tailscale — Zero-config VPN for secure access
- Cloudflare — Edge security and tunneling
- BATS — Bash testing framework