Skip to content

Instantly share code, notes, and snippets.

@drhema
Last active May 13, 2026 09:59
Show Gist options
  • Select an option

  • Save drhema/0895ccc472e5a7dcf656abe1d741441b to your computer and use it in GitHub Desktop.

Select an option

Save drhema/0895ccc472e5a7dcf656abe1d741441b to your computer and use it in GitHub Desktop.
#!/bin/bash
set -euo pipefail
# =============================================================================
# Portainer Hosting Platform — SSH Install Script (Phase 0 fallback)
# Mirror of hosting-platform/cloud-init/ubuntu-24-portainer.yml for cases
# where cloud-init is not available, or for re-applying on an existing box.
# =============================================================================
#
# USAGE:
# CF_TUNNEL_TOKEN=eyJxxxxx bash setup.sh
# # or set env vars first, then ./setup.sh
#
# PRE-REQUISITES (one-time, in Cloudflare Zero Trust):
# 1. Create a tunnel named "hosting-portainer", copy the token.
# 2. Add a public hostname routing portainer.your-domain.com -> http://portainer:9000
# 3. Create a Cloudflare Access app protecting that hostname with email allowlist.
#
# WHAT THIS DOES:
# - Installs Docker, UFW (22/80/443 only), fail2ban, swap, time sync
# - Writes Traefik dynamic configs and infra docker-compose files
# - Creates edge-public + edge-egress Docker networks
# - Starts Traefik + Portainer + cloudflared
# - Portainer is reachable only via your Cloudflare Tunnel + Access app
# (with 127.0.0.1:9443 emergency fallback via SSH local-forward)
#
# IDEMPOTENT: safe to re-run on an already-provisioned box.
# =============================================================================
echo "========================================"
echo " Hosting Platform — Phase 0 Setup"
echo " Docker + Traefik + Portainer + Cloudflared"
echo "========================================"
# -------------------------------------------
# CONFIGURATION — pass via env or prompt
# -------------------------------------------
CF_TUNNEL_TOKEN="${CF_TUNNEL_TOKEN:-}"
SERVER_HOSTNAME="${SERVER_HOSTNAME:-$(hostname)}"
HOSTING_DIR="/opt/hosting"
if [ -z "$CF_TUNNEL_TOKEN" ]; then
echo ""
echo "[CONFIG] CF_TUNNEL_TOKEN is empty."
if [ -t 0 ]; then
echo " Paste it now and press Enter (or leave empty to skip cloudflared):"
read -r CF_TUNNEL_TOKEN || CF_TUNNEL_TOKEN=""
else
echo " Non-interactive run; skipping prompt. cloudflared will not start."
echo " To enable later: edit /opt/hosting/infra/.env, then"
echo " cd /opt/hosting/infra && docker compose --env-file .env -f docker-compose.cloudflared.yml up -d"
fi
fi
echo "[CONFIG] Hostname: $SERVER_HOSTNAME"
echo "[CONFIG] Base directory: $HOSTING_DIR"
if [ -n "$CF_TUNNEL_TOKEN" ]; then
echo "[CONFIG] CF Tunnel token: set"
else
echo "[CONFIG] CF Tunnel token: EMPTY (cloudflared will be skipped)"
fi
echo ""
# Helper: wait for apt lock
wait_for_apt() {
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || \
fuser /var/lib/apt/lists/lock >/dev/null 2>&1 || \
fuser /var/lib/dpkg/lock >/dev/null 2>&1; do
echo " Waiting for apt lock..."
sleep 3
done
}
# -------------------------------------------
# STEP 1: Disable auto-updates & clear apt locks
# -------------------------------------------
echo "[1/12] Disabling unattended-upgrades and clearing apt locks..."
systemctl stop unattended-upgrades 2>/dev/null || true
systemctl disable unattended-upgrades 2>/dev/null || true
systemctl stop apt-daily.timer 2>/dev/null || true
systemctl stop apt-daily-upgrade.timer 2>/dev/null || true
systemctl disable apt-daily.timer 2>/dev/null || true
systemctl disable apt-daily-upgrade.timer 2>/dev/null || true
pkill -9 apt 2>/dev/null || true
pkill -9 dpkg 2>/dev/null || true
pkill -9 apt-get 2>/dev/null || true
rm -f /var/lib/dpkg/lock-frontend 2>/dev/null || true
rm -f /var/lib/apt/lists/lock 2>/dev/null || true
rm -f /var/lib/dpkg/lock 2>/dev/null || true
rm -f /var/cache/apt/archives/lock 2>/dev/null || true
dpkg --configure -a 2>/dev/null || true
# -------------------------------------------
# STEP 2: Update + install base packages
# -------------------------------------------
echo "[2/12] Installing base packages..."
wait_for_apt
apt-get update -q
wait_for_apt
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -q
wait_for_apt
DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
ca-certificates curl gnupg jq fail2ban ufw openssl
# -------------------------------------------
# STEP 3: Folder structure
# -------------------------------------------
echo "[3/12] Creating folder structure..."
mkdir -p "$HOSTING_DIR"/{traefik/dynamic,traefik/acme,certs,backups/tmp,portainer,scripts,infra,sites}
touch "$HOSTING_DIR/traefik/acme/acme.json"
chmod 600 "$HOSTING_DIR/traefik/acme/acme.json"
# -------------------------------------------
# STEP 4: 8 GB swap
# -------------------------------------------
echo "[4/12] Configuring swap (8 GB)..."
if ! swapon --show 2>/dev/null | grep -q /swapfile; then
fallocate -l 8G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
if ! grep -q "/swapfile" /etc/fstab; then
echo "/swapfile none swap sw 0 0" >> /etc/fstab
fi
sysctl -w vm.swappiness=10 >/dev/null
echo " Swap enabled."
else
echo " Swap already active, skipping."
fi
# -------------------------------------------
# STEP 5: Time sync
# -------------------------------------------
echo "[5/12] Enabling time sync..."
timedatectl set-ntp true
# -------------------------------------------
# STEP 6: Install Docker
# -------------------------------------------
echo "[6/12] Installing Docker..."
if ! command -v docker >/dev/null 2>&1; then
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
wait_for_apt
apt-get update -q
wait_for_apt
DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
docker-ce docker-ce-cli containerd.io docker-compose-plugin
else
echo " Docker already installed: $(docker --version)"
fi
systemctl enable docker >/dev/null
systemctl start docker
# -------------------------------------------
# STEP 7: Firewall (UFW)
# -------------------------------------------
echo "[7/12] Configuring firewall (UFW)..."
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # Traefik HTTP (mostly for HTTP→HTTPS redirect; can be removed once all sites use CF Tunnel)
ufw allow 443/tcp # Traefik HTTPS (can be removed once all sites use CF Tunnel)
ufw allow 15432:15999/tcp comment "provisioned-postgres" # for backup-manager's one-click Postgres provisioning
ufw allow 16379:16999/tcp comment "provisioned-redis" # for backup-manager's one-click Redis provisioning
# Do NOT open 9443 — Portainer is reachable only via Cloudflare Tunnel.
# Once you've migrated all HTTP sites to CF Tunnel hostnames, you can also
# 'ufw delete allow 80/tcp' and 'ufw delete allow 443/tcp' — tunnel-only mode.
echo "y" | ufw enable
# -------------------------------------------
# STEP 8: Self-signed origin cert (Traefik fallback default)
# -------------------------------------------
echo "[8/12] Generating self-signed origin cert..."
if [ ! -f "$HOSTING_DIR/certs/origin.crt" ]; then
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout "$HOSTING_DIR/certs/origin.key" \
-out "$HOSTING_DIR/certs/origin.crt" \
-subj "/CN=hosting-origin" 2>/dev/null
echo " Self-signed cert created."
else
echo " Self-signed cert exists, skipping."
fi
# -------------------------------------------
# STEP 9: Traefik dynamic configs
# -------------------------------------------
echo "[9/12] Writing Traefik dynamic configs..."
cat > "$HOSTING_DIR/traefik/dynamic/middlewares.yml" << 'MWEOF'
http:
middlewares:
strip-server-headers:
headers:
customResponseHeaders:
Server: ""
X-Powered-By: ""
X-AspNet-Version: ""
X-AspNetMvc-Version: ""
site-rate-limit:
rateLimit:
average: 100
burst: 200
period: 1s
auth-rate-limit:
rateLimit:
average: 5
burst: 10
period: 1s
secure-headers:
headers:
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
MWEOF
cat > "$HOSTING_DIR/traefik/dynamic/tls-default.yml" << 'TLSEOF'
tls:
stores:
default:
defaultCertificate:
certFile: /certs/origin.crt
keyFile: /certs/origin.key
TLSEOF
# -------------------------------------------
# STEP 10: Infra docker-compose files
# -------------------------------------------
echo "[10/12] Writing infra docker-compose files..."
cat > "$HOSTING_DIR/infra/docker-compose.traefik.yml" << 'TRAEFIKEOF'
services:
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
command:
- "--api.dashboard=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=edge-public"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /opt/hosting/traefik/dynamic:/etc/traefik/dynamic:ro
- /opt/hosting/certs:/certs:ro
- /opt/hosting/traefik/acme:/acme
networks:
- edge-public
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 64M
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
networks:
edge-public:
external: true
TRAEFIKEOF
cat > "$HOSTING_DIR/infra/docker-compose.portainer.yml" << 'PORTAINEREOF'
services:
portainer:
image: portainer/portainer-ce:2.39.2
container_name: portainer
restart: unless-stopped
# 9443 published on localhost only — emergency fallback via SSH local-forward.
# External access goes through Cloudflare Tunnel -> http://portainer:9000.
ports:
- "127.0.0.1:9443:9443"
expose:
- "9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
networks:
- edge-public
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
volumes:
portainer_data:
networks:
edge-public:
external: true
PORTAINEREOF
cat > "$HOSTING_DIR/infra/docker-compose.cloudflared.yml" << 'CFEOF'
# cloudflared — the only entry point for every web app on this server.
# All HTTP services (Portainer admin, backup-manager UI, future site frontends
# and backends) are reached via public hostnames added to THIS tunnel in the CF
# dashboard. One tunnel + many hostnames + zero per-site cert work.
#
# HA: change replicas to 2 if you want failover. Both connectors join the same
# tunnel; CF load-balances. Extra ~60 MB RAM. Recommended for >5 sites.
services:
cloudflared:
image: cloudflare/cloudflared:2026.3.0
# No container_name when using replicas — Docker would conflict.
# Single-instance keeps container_name for easy `docker logs cloudflared`.
container_name: cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run --token ${CF_TUNNEL_TOKEN}
networks:
- edge-public
deploy:
replicas: 1 # set to 2 for HA (then remove container_name above)
resources:
limits:
memory: 128M
reservations:
memory: 32M
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
networks:
edge-public:
external: true
CFEOF
cat > "$HOSTING_DIR/infra/.env" << ENVEOF
CF_TUNNEL_TOKEN=$CF_TUNNEL_TOKEN
ENVEOF
chmod 600 "$HOSTING_DIR/infra/.env"
# -------------------------------------------
# STEP 11: Docker networks
# -------------------------------------------
echo "[11/12] Creating Docker networks..."
docker network create edge-public 2>/dev/null || echo " edge-public exists, skipping."
docker network create edge-egress 2>/dev/null || echo " edge-egress exists, skipping."
# -------------------------------------------
# STEP 12: Start infra stacks
# -------------------------------------------
echo "[12/12] Starting infra stacks..."
cd "$HOSTING_DIR/infra"
docker compose -f docker-compose.traefik.yml up -d
sleep 5
docker compose -f docker-compose.portainer.yml up -d
if [ -n "$CF_TUNNEL_TOKEN" ]; then
docker compose --env-file .env -f docker-compose.cloudflared.yml up -d
else
echo " [SKIP] cloudflared not started (CF_TUNNEL_TOKEN was empty)."
echo " Set the token in $HOSTING_DIR/infra/.env and run:"
echo " cd $HOSTING_DIR/infra && docker compose --env-file .env -f docker-compose.cloudflared.yml up -d"
fi
# -------------------------------------------
# Wait for Portainer
# -------------------------------------------
echo ""
echo "Waiting for Portainer to come up..."
for i in $(seq 1 30); do
if curl -sk https://localhost:9443/api/status 2>/dev/null | grep -q "Version"; then
echo " Portainer is responding."
break
fi
sleep 5
done
SERVER_IP=$(curl -s -4 ifconfig.me 2>/dev/null || echo "?")
# -------------------------------------------
# Summary
# -------------------------------------------
echo ""
echo "========================================"
echo " Phase 0 Setup Complete"
echo "========================================"
echo ""
echo " Server IP: $SERVER_IP"
echo " Hostname: $SERVER_HOSTNAME"
echo " Base directory: $HOSTING_DIR"
echo ""
echo " Containers:"
docker ps --format " {{.Names}} ({{.Status}})" | grep -E 'traefik|portainer|cloudflared' || true
echo ""
echo " Networks:"
docker network ls --format " {{.Name}}" | grep -E '^edge-' || true
echo ""
echo " Swap:"
swapon --show 2>/dev/null | tail -n +2 | awk '{print " " $0}' || echo " (none)"
echo ""
echo " Firewall (UFW):"
ufw status numbered | grep -E '22|80|443|15432|16379' || true
echo ""
echo " Folders:"
echo " $HOSTING_DIR/traefik/dynamic/ (middlewares.yml, tls-default.yml, per-site files — legacy path)"
echo " $HOSTING_DIR/certs/ (origin.crt fallback — legacy path)"
echo " $HOSTING_DIR/backups/tmp/ (backup manager scratch space)"
echo " $HOSTING_DIR/infra/ (this stack's compose files + .env)"
echo ""
echo " Next steps:"
echo " 1. Confirm CF Zero Trust shows tunnel HEALTHY."
echo " 2. Open your protected Portainer URL via Cloudflare Access."
echo " 3. Create Portainer admin user on first-time-setup screen → enable 2FA."
echo " 4. Deploy backup-manager stack via Portainer (compose at hosting-platform/backup-manager/)."
echo " 5. Set these env vars on backup-manager + redeploy to enable the provisioning UI:"
echo " CF_API_TOKEN, CF_ZONE_ID, CF_BASE_DOMAIN"
echo " PORTAINER_API_KEY, PORTAINER_URL, PORTAINER_ENDPOINT_ID"
echo " ENCRYPTION_KEY (openssl rand -hex 32)"
echo " 6. Open https://backup.<your-domain> → /provision/* — one-click Postgres/Redis/Typesense."
echo " 7. For each new site app: deploy stack via Portainer (no ports, no Traefik labels)"
echo " + add a public hostname in your existing CF Tunnel pointing to <container>:<port>."
echo ""
echo " Default deployment model:"
echo " Web apps (frontends, backends, search) → CF Tunnel hostnames (no port, no cert work)"
echo " Postgres / Redis → unproxied DNS + direct TCP (port ranges above)"
echo ""
echo " Emergency Portainer access (if CF Tunnel is down):"
echo " ssh -L 9443:127.0.0.1:9443 root@$SERVER_IP"
echo " Then open https://localhost:9443 in your browser."
echo ""
echo " Custom images this platform uses (already public on GHCR):"
echo " ghcr.io/drhema/backup-manager — UI + provisioning logic"
echo " ghcr.io/drhema/postgres-everything:17 — Postgres + PostGIS + pgvector + auto-SSL"
echo " ghcr.io/drhema/redis-tls:7.4 — Redis with TLS-only + auto-SSL"
echo "========================================"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment