Last active
May 13, 2026 09:59
-
-
Save drhema/0895ccc472e5a7dcf656abe1d741441b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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