From 67524e22baac70d077968625bd83cd428e9fcaea Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Tue, 20 Jan 2026 21:22:49 -0600 Subject: [PATCH 01/17] feat: add demo environment with Cloudflare Tunnel Set up a demo/staging environment for local VM deployment with: - Cloudflare Tunnel for public internet access (no firewall needed) - Let's Encrypt SSL via DNS-01 challenge (Cloudflare DNS API) - Services: beoftexas, strapi, wbaoftexas New infrastructure: - cloudflared container for tunnel connectivity - nginx-demo with certbot/dns-cloudflare for SSL certs - Demo-specific playbooks for each service Co-Authored-By: Claude Opus 4.5 --- beoftexas/docker-compose-demo.yaml | 52 +++++++++ beoftexas/docker/nginx-demo/default.conf | 55 +++++++++ beoftexas/playbook-demo.yaml | 88 ++++++++++++++ cloudflared/docker-compose.yaml | 14 +++ cloudflared/playbook.yaml | 47 ++++++++ deploy-demo.sh | 109 ++++++++++++++++++ hosts-demo.yaml | 10 ++ .../conf/demo-strapi.beoftexas.com.conf | 37 ++++++ nginx-demo/conf/demo.beoftexas.com.conf | 37 ++++++ nginx-demo/conf/demo.wbaoftexas.com.conf | 37 ++++++ nginx-demo/docker-compose.yaml | 27 +++++ nginx-demo/playbook.yaml | 86 ++++++++++++++ secret/demo/beoftexas.env | Bin 0 -> 473 bytes secret/demo/cloudflare.ini | Bin 0 -> 281 bytes secret/demo/cloudflare_tunnel_token.txt | Bin 0 -> 56 bytes secret/demo/strapi_db_pw.txt | Bin 0 -> 43 bytes secret/demo/wbaoftexas_db_pw.txt | Bin 0 -> 47 bytes secret/demo/wbaoftexas_db_root_pw.txt | Bin 0 -> 52 bytes strapi/playbook-demo.yaml | 51 ++++++++ wbaoftexas/playbook-demo.yaml | 53 +++++++++ 20 files changed, 703 insertions(+) create mode 100644 beoftexas/docker-compose-demo.yaml create mode 100644 beoftexas/docker/nginx-demo/default.conf create mode 100644 beoftexas/playbook-demo.yaml create mode 100644 cloudflared/docker-compose.yaml create mode 100644 cloudflared/playbook.yaml create mode 100755 deploy-demo.sh create mode 100644 hosts-demo.yaml create mode 100644 nginx-demo/conf/demo-strapi.beoftexas.com.conf create mode 100644 nginx-demo/conf/demo.beoftexas.com.conf create mode 100644 nginx-demo/conf/demo.wbaoftexas.com.conf create mode 100644 nginx-demo/docker-compose.yaml create mode 100644 nginx-demo/playbook.yaml create mode 100644 secret/demo/beoftexas.env create mode 100644 secret/demo/cloudflare.ini create mode 100644 secret/demo/cloudflare_tunnel_token.txt create mode 100644 secret/demo/strapi_db_pw.txt create mode 100644 secret/demo/wbaoftexas_db_pw.txt create mode 100644 secret/demo/wbaoftexas_db_root_pw.txt create mode 100644 strapi/playbook-demo.yaml create mode 100644 wbaoftexas/playbook-demo.yaml diff --git a/beoftexas/docker-compose-demo.yaml b/beoftexas/docker-compose-demo.yaml new file mode 100644 index 0000000..2dba101 --- /dev/null +++ b/beoftexas/docker-compose-demo.yaml @@ -0,0 +1,52 @@ +name: beoftexas +services: + nginx: + image: nginx:alpine + networks: + shared: + aliases: + - beoftexas-web + default: + volumes: + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - public_files:/var/www/html/public:ro + depends_on: + - web + restart: always + + web: + image: casmith/beoftexas:2026.01.20.919c851e + env_file: + - .env + networks: + default: + environment: + DB_NAME: beoftexas + DB_HOST: mariadb + DB_USER: beoftexas + STRAPI_URL: https://demo-strapi.beoftexas.com + volumes: + - public_files:/var/www/html/public + depends_on: + - mariadb + restart: always + + mariadb: + image: mariadb + restart: always + env_file: + - .env + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: beoftexas + MYSQL_USER: beoftexas + MYSQL_PASSWORD: ${DB_PASSWORD} + volumes: + - ./data/mariadb/data:/var/lib/mysql + +volumes: + public_files: + +networks: + shared: + external: true diff --git a/beoftexas/docker/nginx-demo/default.conf b/beoftexas/docker/nginx-demo/default.conf new file mode 100644 index 0000000..15fdb55 --- /dev/null +++ b/beoftexas/docker/nginx-demo/default.conf @@ -0,0 +1,55 @@ +server { + listen 8000; + server_name _; + root /var/www/html/public; + index index.php index.html; + + # Security headers + 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 Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Cross-Origin-Resource-Policy "same-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://demo-strapi.beoftexas.com;" always; + + # Serve static files directly + # Note: add_header in location blocks overrides parent headers, so we repeat security headers here + location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + 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 Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + try_files $uri =404; + } + + # Main location block + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # PHP-FPM proxy + location ~ \.php$ { + fastcgi_pass web:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # FastCGI settings + fastcgi_connect_timeout 60; + fastcgi_send_timeout 180; + fastcgi_read_timeout 180; + fastcgi_buffer_size 128k; + fastcgi_buffers 4 256k; + fastcgi_busy_buffers_size 256k; + } + + # Deny access to hidden files + location ~ /\. { + deny all; + } +} diff --git a/beoftexas/playbook-demo.yaml b/beoftexas/playbook-demo.yaml new file mode 100644 index 0000000..3c87cd1 --- /dev/null +++ b/beoftexas/playbook-demo.yaml @@ -0,0 +1,88 @@ +- name: Deploy Benefit Elect of Texas website via Docker (Demo) + hosts: demo + become: true + tasks: + - name: Log into DockerHub + docker_login: + username: '{{ lookup("env", "DOCKERHUB_USERNAME") }}' + password: '{{ lookup("env", "DOCKERHUB_PASSWORD") }}' + + - name: Creates directory + ansible.builtin.file: + path: "{{ item }}" + state: directory + loop: + - "{{ deploy_path }}" + - "{{ deploy_path }}/beoftexas" + - "{{ deploy_path }}/beoftexas/data" + - "{{ deploy_path }}/beoftexas/docker/nginx" + + - name: Create shared Docker network + community.docker.docker_network: + name: shared + state: present + + - name: copy Docker Compose files + copy: + src: "docker-compose-demo.yaml" + dest: "{{ deploy_path }}/beoftexas/docker-compose.yaml" + + - name: copy nginx config (demo version with updated CSP) + copy: + src: "docker/nginx-demo/default.conf" + dest: "{{ deploy_path }}/beoftexas/docker/nginx/default.conf" + + - name: copy environment file + copy: + src: "../secret/demo/beoftexas.env" + dest: "{{ deploy_path }}/beoftexas/.env" + mode: '0600' + + - name: Deploy with docker compose + ansible.builtin.shell: + cmd: "docker compose up -d" + chdir: "{{ deploy_path }}/beoftexas/" + register: compose_up_result + + - name: Gracefully reload beoftexas nginx config + ansible.builtin.shell: + cmd: "docker compose exec -T nginx nginx -s reload" + chdir: "{{ deploy_path }}/beoftexas/" + register: nginx_reload_result + failed_when: false + + - name: Wait for PHP-FPM container to be ready + ansible.builtin.shell: + cmd: "docker compose exec -T web php -v" + chdir: "{{ deploy_path }}/beoftexas/" + register: php_check + retries: 10 + delay: 3 + until: php_check.rc == 0 + + - name: Wait for MariaDB to be ready + ansible.builtin.shell: + cmd: "docker compose exec -T mariadb mariadb -u beoftexas -p$(grep '^DB_PASSWORD=' .env | cut -d'=' -f2-) -e 'SELECT 1' 2>&1" + chdir: "{{ deploy_path }}/beoftexas/" + register: db_check + retries: 30 + delay: 2 + until: db_check.rc == 0 + + - name: Run database migrations + ansible.builtin.shell: + cmd: "docker compose exec -T web php vendor/bin/phinx migrate -e production" + chdir: "{{ deploy_path }}/beoftexas/" + register: migration_result + + - name: Display migration output + ansible.builtin.debug: + var: migration_result.stdout_lines + + - name: Restart reverse proxy nginx to pick up beoftexas on shared network + ansible.builtin.shell: + cmd: "docker compose restart web" + chdir: "{{ deploy_path }}/nginx-demo" + register: nginx_restart_result + failed_when: false + changed_when: nginx_restart_result.rc == 0 diff --git a/cloudflared/docker-compose.yaml b/cloudflared/docker-compose.yaml new file mode 100644 index 0000000..a63a911 --- /dev/null +++ b/cloudflared/docker-compose.yaml @@ -0,0 +1,14 @@ +name: cloudflared +services: + tunnel: + image: cloudflare/cloudflared:latest + command: tunnel --no-autoupdate run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - shared + restart: always + +networks: + shared: + external: true diff --git a/cloudflared/playbook.yaml b/cloudflared/playbook.yaml new file mode 100644 index 0000000..37b9e58 --- /dev/null +++ b/cloudflared/playbook.yaml @@ -0,0 +1,47 @@ +- name: Deploy Cloudflare Tunnel via Docker + hosts: demo + become: true + tasks: + - name: Creates directory + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ deploy_path }}/cloudflared" + - "{{ deploy_path }}/cloudflared/secrets" + + - name: Create shared Docker network + community.docker.docker_network: + name: shared + state: present + + - name: Copy Docker Compose files + ansible.builtin.copy: + src: "{{ item }}" + dest: "{{ deploy_path }}/cloudflared/{{ item }}" + mode: "0644" + loop: + - docker-compose.yaml + + - name: Copy tunnel token secret + ansible.builtin.copy: + src: "../secret/demo/cloudflare_tunnel_token.txt" + dest: "{{ deploy_path }}/cloudflared/secrets/cloudflare_tunnel_token.txt" + mode: "0600" + + - name: Deploy with docker compose + ansible.builtin.shell: + cmd: "export CLOUDFLARE_TUNNEL_TOKEN=$(cat secrets/cloudflare_tunnel_token.txt) && docker compose up -d" + chdir: "{{ deploy_path }}/cloudflared/" + register: compose_up_result + + - name: Display cloudflared status + ansible.builtin.shell: + cmd: "docker compose logs --tail=20" + chdir: "{{ deploy_path }}/cloudflared/" + register: tunnel_logs + + - name: Show tunnel logs + ansible.builtin.debug: + var: tunnel_logs.stdout_lines diff --git a/deploy-demo.sh b/deploy-demo.sh new file mode 100755 index 0000000..63a0b77 --- /dev/null +++ b/deploy-demo.sh @@ -0,0 +1,109 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +INVENTORY="hosts-demo.yaml" + +# Get server IP from inventory or use default +SERVER_IP="${1:-$(grep 'ansible_host:' $INVENTORY | head -1 | awk '{print $2}')}" + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Demo Environment Deployment Script${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo "Target server: $SERVER_IP" +echo "Inventory: $INVENTORY" +echo "" + +# Function to run a playbook with nice output +run_playbook() { + local playbook=$1 + local description=$2 + + echo -e "${YELLOW}>>> $description${NC}" + echo "Running: ansible-playbook -i $INVENTORY $playbook" + + if ansible-playbook -i "$INVENTORY" "$playbook"; then + echo -e "${GREEN}✓ $description completed${NC}" + echo "" + else + echo -e "${RED}✗ $description failed${NC}" + exit 1 + fi +} + +# Check prerequisites +echo -e "${YELLOW}>>> Checking prerequisites...${NC}" + +# Check if secret files have been configured +if grep -q "YOUR_CLOUDFLARE_API_TOKEN_HERE" secret/demo/cloudflare.ini 2>/dev/null; then + echo -e "${RED}ERROR: Cloudflare API token not configured${NC}" + echo "Please edit secret/demo/cloudflare.ini with your Cloudflare API token" + exit 1 +fi + +if grep -q "YOUR_CLOUDFLARE_TUNNEL_TOKEN_HERE" secret/demo/cloudflare_tunnel_token.txt 2>/dev/null; then + echo -e "${RED}ERROR: Cloudflare tunnel token not configured${NC}" + echo "Please edit secret/demo/cloudflare_tunnel_token.txt with your tunnel token" + exit 1 +fi + +echo -e "${GREEN}✓ Prerequisites check passed${NC}" +echo "" + +# Check if we need to bootstrap (use root password) +echo -e "${YELLOW}>>> Checking if server is bootstrapped...${NC}" +if ! ssh -o BatchMode=yes -o ConnectTimeout=5 ubuntu@$SERVER_IP exit 2>/dev/null; then + echo "Server needs bootstrapping (will prompt for root password)" + echo -e "${YELLOW}>>> Step 1: Bootstrap server (create ubuntu user, SSH keys, disable password auth)${NC}" + ansible-playbook -i "$SERVER_IP," bootstrap.yaml --ask-pass + echo -e "${GREEN}✓ Bootstrap completed${NC}" + echo "" +else + echo "Server already bootstrapped, skipping..." + echo "" +fi + +# Now run all deployment playbooks in order +run_playbook "install-docker.yaml" "Step 2: Install Docker" +run_playbook "strapi/playbook-demo.yaml" "Step 3: Deploy Strapi (PostgreSQL CMS)" +run_playbook "beoftexas/playbook-demo.yaml" "Step 4: Deploy Benefit Elect of Texas" +run_playbook "wbaoftexas/playbook-demo.yaml" "Step 5: Deploy WBA of Texas" +run_playbook "nginx-demo/playbook.yaml" "Step 6: Deploy Nginx (reverse proxy + SSL certs)" +run_playbook "cloudflared/playbook.yaml" "Step 7: Deploy Cloudflare Tunnel" + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Demo Deployment Complete!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo "All services deployed successfully!" +echo "" +echo "Deployed services:" +echo " ✓ Strapi CMS (PostgreSQL)" +echo " ✓ Benefit Elect of Texas (MariaDB)" +echo " ✓ WBA of Texas (MariaDB)" +echo " ✓ Nginx reverse proxy with Let's Encrypt SSL" +echo " ✓ Cloudflare Tunnel" +echo "" +echo "Demo URLs:" +echo " - https://demo.beoftexas.com" +echo " - https://demo-strapi.beoftexas.com" +echo " - https://demo.wbaoftexas.com" +echo "" +echo "Verification steps:" +echo " 1. SSH to demo VM and check containers: docker ps" +echo " 2. Check cloudflared logs: docker logs cloudflared-tunnel-1" +echo " 3. Verify certs: ls /home/ubuntu/deploy/nginx-demo/data/certbot/conf/live/" +echo " 4. Test URLs in browser" +echo "" +echo "To restore databases from production backups:" +echo " ansible-playbook -i $INVENTORY beoftexas/restore-db.yaml" +echo " ansible-playbook -i $INVENTORY wbaoftexas/restore-db.yaml" +echo " ansible-playbook -i $INVENTORY strapi/restore-db.yaml" +echo "" diff --git a/hosts-demo.yaml b/hosts-demo.yaml new file mode 100644 index 0000000..a2d406f --- /dev/null +++ b/hosts-demo.yaml @@ -0,0 +1,10 @@ +all: + vars: + ansible_python_interpreter: /usr/bin/python3 + children: + servers: + hosts: + demo: + ansible_host: 192.168.1.100 # Replace with your demo VM's IP address + ansible_user: ubuntu + deploy_path: /home/ubuntu/deploy diff --git a/nginx-demo/conf/demo-strapi.beoftexas.com.conf b/nginx-demo/conf/demo-strapi.beoftexas.com.conf new file mode 100644 index 0000000..e410b28 --- /dev/null +++ b/nginx-demo/conf/demo-strapi.beoftexas.com.conf @@ -0,0 +1,37 @@ +server { + listen 80; + listen [::]:80; + + server_name demo-strapi.beoftexas.com; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://demo-strapi.beoftexas.com$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + + server_name demo-strapi.beoftexas.com; + + ssl_certificate /etc/letsencrypt/live/demo.beoftexas.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/demo.beoftexas.com/privkey.pem; + + # Use Docker's internal DNS server for runtime resolution + resolver 127.0.0.11 valid=10s; + + location / { + set $backend "strapi-web:1337"; + proxy_pass http://$backend; + 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; + } +} diff --git a/nginx-demo/conf/demo.beoftexas.com.conf b/nginx-demo/conf/demo.beoftexas.com.conf new file mode 100644 index 0000000..2bed857 --- /dev/null +++ b/nginx-demo/conf/demo.beoftexas.com.conf @@ -0,0 +1,37 @@ +server { + listen 80; + listen [::]:80; + + server_name demo.beoftexas.com; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://demo.beoftexas.com$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + + server_name demo.beoftexas.com; + + ssl_certificate /etc/letsencrypt/live/demo.beoftexas.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/demo.beoftexas.com/privkey.pem; + + # Use Docker's internal DNS server for runtime resolution + resolver 127.0.0.11 valid=10s; + + location / { + set $backend "beoftexas-web:8000"; + proxy_pass http://$backend; + 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; + } +} diff --git a/nginx-demo/conf/demo.wbaoftexas.com.conf b/nginx-demo/conf/demo.wbaoftexas.com.conf new file mode 100644 index 0000000..5fb2346 --- /dev/null +++ b/nginx-demo/conf/demo.wbaoftexas.com.conf @@ -0,0 +1,37 @@ +server { + listen 80; + listen [::]:80; + + server_name demo.wbaoftexas.com; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://demo.wbaoftexas.com$request_uri; + } +} + +server { + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2; + + server_name demo.wbaoftexas.com; + + ssl_certificate /etc/letsencrypt/live/demo.beoftexas.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/demo.beoftexas.com/privkey.pem; + + # Use Docker's internal DNS server for runtime resolution + resolver 127.0.0.11 valid=10s; + + location / { + set $backend "wbaoftexas-web:8000"; + proxy_pass http://$backend; + 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; + } +} diff --git a/nginx-demo/docker-compose.yaml b/nginx-demo/docker-compose.yaml new file mode 100644 index 0000000..ea44923 --- /dev/null +++ b/nginx-demo/docker-compose.yaml @@ -0,0 +1,27 @@ +name: nginx-demo +services: + web: + image: nginx:1.25-alpine + networks: + shared: + aliases: + - nginx-web + default: + volumes: + - ./data/nginx:/etc/nginx/conf.d + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + restart: always + + certbot: + image: certbot/dns-cloudflare + volumes: + - ./data/cloudflare.ini:/etc/cloudflare.ini:ro + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + restart: always + +networks: + shared: + external: true diff --git a/nginx-demo/playbook.yaml b/nginx-demo/playbook.yaml new file mode 100644 index 0000000..a2b5de6 --- /dev/null +++ b/nginx-demo/playbook.yaml @@ -0,0 +1,86 @@ +- name: Deploy nginx reverse proxy with certbot DNS-01 via Docker + hosts: demo + become: true + vars: + cert_email: admin@beoftexas.com + demo_domains: + - demo.beoftexas.com + - demo-strapi.beoftexas.com + - demo.wbaoftexas.com + tasks: + - name: Creates directory + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ deploy_path }}/nginx-demo" + - "{{ deploy_path }}/nginx-demo/data" + - "{{ deploy_path }}/nginx-demo/data/nginx" + - "{{ deploy_path }}/nginx-demo/data/certbot" + - "{{ deploy_path }}/nginx-demo/data/certbot/conf" + - "{{ deploy_path }}/nginx-demo/data/certbot/www" + + - name: Create shared Docker network + community.docker.docker_network: + name: shared + state: present + + - name: Copy Docker Compose files + ansible.builtin.copy: + src: "{{ item }}" + dest: "{{ deploy_path }}/nginx-demo/{{ item }}" + mode: "0644" + loop: + - docker-compose.yaml + + - name: Copy Cloudflare credentials for DNS-01 challenge + ansible.builtin.copy: + src: "../secret/demo/cloudflare.ini" + dest: "{{ deploy_path }}/nginx-demo/data/cloudflare.ini" + mode: "0600" + + - name: Copy all nginx config files from conf directory + ansible.builtin.copy: + src: "conf/" + dest: "{{ deploy_path }}/nginx-demo/data/nginx/" + mode: "0644" + + - name: Check if certificates already exist + ansible.builtin.stat: + path: "{{ deploy_path }}/nginx-demo/data/certbot/conf/live/demo.beoftexas.com/fullchain.pem" + register: cert_stat + + - name: Issue SSL certificates via DNS-01 challenge + ansible.builtin.shell: + cmd: | + docker compose run --rm certbot certonly \ + --dns-cloudflare \ + --dns-cloudflare-credentials /etc/cloudflare.ini \ + --dns-cloudflare-propagation-seconds 30 \ + -d {{ demo_domains | join(' -d ') }} \ + --agree-tos \ + --email {{ cert_email }} \ + --non-interactive + chdir: "{{ deploy_path }}/nginx-demo/" + register: certbot_result + when: not cert_stat.stat.exists + + - name: Display certbot output + ansible.builtin.debug: + var: certbot_result.stdout_lines + when: certbot_result is defined and certbot_result.stdout_lines is defined + + - name: Deploy with docker compose + ansible.builtin.shell: + cmd: "docker compose up -d" + chdir: "{{ deploy_path }}/nginx-demo/" + register: compose_up_result + + - name: Gracefully reload nginx to apply config changes + ansible.builtin.shell: + cmd: "docker compose exec -T web nginx -s reload" + chdir: "{{ deploy_path }}/nginx-demo/" + register: nginx_reload_result + failed_when: false + changed_when: nginx_reload_result.rc == 0 diff --git a/secret/demo/beoftexas.env b/secret/demo/beoftexas.env new file mode 100644 index 0000000000000000000000000000000000000000..55077e65c7566f33a2d12095e975346431372085 GIT binary patch literal 473 zcmV;~0Ve(cM@dveQdv+`000G}co}mG6Y0zw!bJxX=TiD|Cb)uy|3$i-+1cyw@E2~iIOlf2 zX)K$>q-b3PLZ5eG~3?XlZqHJx)5z+@Jaw+)x#^Va9zP7PLbm<?oanZAAmCogmNU} z#>G))MliK>&%Lzg*wezn-2Rr<)X@9H311BmkTa*|Pv>ojvE!^>-m}HZ|4+Y!jR{4) PbW0b;(E&9INY$6f!zAa+ literal 0 HcmV?d00001 diff --git a/secret/demo/cloudflare.ini b/secret/demo/cloudflare.ini new file mode 100644 index 0000000000000000000000000000000000000000..ed8820afb868705543a854370edea29e0a3d277f GIT binary patch literal 281 zcmV+!0p|VyM@dveQdv+`0I;d2mZcrg5=|w=M`~pp%mPk;D7JUsQk@!xX=Fgyvb?MbrHF zTNN`C9Re!hQX7>hHkO8?#~m-v_u8Q-!}h$)sFF$C2{E=Bt6%AV{Lze~)9Lx?q|VsO zw)0+>;JCQMZaq}u9q(6xE})l>{1qx#Z{)6WcH!7?b98Qw1ABk<@?^bpWj2qRN}NN| z!cwi{Feo_K1pn{ZSho^@kf(L0!zfUVbyG5qX9W4{N>OXa4tsmWvz6WTia5|KZR*)4 fVHK71e+j#Ntn9tK-U!X?n3g_~%xQ~zDc9;^7xIig literal 0 HcmV?d00001 diff --git a/secret/demo/cloudflare_tunnel_token.txt b/secret/demo/cloudflare_tunnel_token.txt new file mode 100644 index 0000000000000000000000000000000000000000..c2672185ff916a6b666ff74b842e42c1b88ceaa4 GIT binary patch literal 56 zcmV-80LT9TM@dveQdv+`03QDO6YxVBPE+4{tB9gjke%azyT>jTv|V literal 0 HcmV?d00001 diff --git a/secret/demo/strapi_db_pw.txt b/secret/demo/strapi_db_pw.txt new file mode 100644 index 0000000000000000000000000000000000000000..88e3335cd4091548c8d24104aadb6a3b70b2b171 GIT binary patch literal 43 zcmZQ@_Y83kiVO&0xYfMb)8vp>x6y;&@f#CGG`j Date: Tue, 20 Jan 2026 23:44:16 -0600 Subject: [PATCH 02/17] chore: configure Cloudflare tunnel credentials - Add API token and tunnel token - Tunnel 'demo-beoftexas' created via API with ingress rules - DNS CNAME records created for all demo domains Co-Authored-By: Claude Opus 4.5 --- hosts-demo.yaml | 2 +- secret/demo/cloudflare.ini | Bin 281 -> 162 bytes secret/demo/cloudflare_tunnel_token.txt | Bin 56 -> 263 bytes 3 files changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts-demo.yaml b/hosts-demo.yaml index a2d406f..c0d746b 100644 --- a/hosts-demo.yaml +++ b/hosts-demo.yaml @@ -5,6 +5,6 @@ all: servers: hosts: demo: - ansible_host: 192.168.1.100 # Replace with your demo VM's IP address + ansible_host: 192.168.10.43 ansible_user: ubuntu deploy_path: /home/ubuntu/deploy diff --git a/secret/demo/cloudflare.ini b/secret/demo/cloudflare.ini index ed8820afb868705543a854370edea29e0a3d277f..a07b8a85ffffa445d28652803118d3d74bda478b 100644 GIT binary patch literal 162 zcmV;T0A2q8M@dveQdv+`09+UHTtFPcxrH`v|IWKy5#)$`;hX6}(iPM`H^;;v^ExI{ z#tSkCLFckeyI7%LwsCFgjdYRX$80!GeHF}289lE4mJ$>GwK#sB#@k}Ya#m-LBKuJ) zF&gHjBT5(^&m05rOn)i0ts~bSc}yfjd;GhtV>(zo?mfICQ9~f8vb?MbrHF zTNN`C9Re!hQX7>hHkO8?#~m-v_u8Q-!}h$)sFF$C2{E=Bt6%AV{Lze~)9Lx?q|VsO zw)0+>;JCQMZaq}u9q(6xE})l>{1qx#Z{)6WcH!7?b98Qw1ABk<@?^bpWj2qRN}NN| z!cwi{Feo_K1pn{ZSho^@kf(L0!zfUVbyG5qX9W4{N>OXa4tsmWvz6WTia5|KZR*)4 fVHK71e+j#Ntn9tK-U!X?n3g_~%xQ~zDc9;^7xIig diff --git a/secret/demo/cloudflare_tunnel_token.txt b/secret/demo/cloudflare_tunnel_token.txt index c2672185ff916a6b666ff74b842e42c1b88ceaa4..a5f30068f36e7280d0cb38ad86a36798a9c2d777 100644 GIT binary patch literal 263 zcmV+i0r>s^M@dveQdv+`09?|)Cr>|*Ue7Eto?BGAVcqxhh6Q3Yr!?{Zh5N#6#8ua& z)zkqGDh71b&Jes2K}5=N#F!;>wS>2dDGwErop}W?a2ifeN=a{e%azyT>jTv|V From ceb9c9d7030a4a4ba06d6fddc5789c34aa6b1d0b Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 01:19:46 -0600 Subject: [PATCH 03/17] fix: strapi database password configuration - Add DATABASE_PASSWORD env var to strapi docker-compose - Update strapi playbook to set password from secret file - Update demo secrets with actual passwords Co-Authored-By: Claude Opus 4.5 --- secret/demo/beoftexas.env | Bin 473 -> 477 bytes secret/demo/strapi_db_pw.txt | Bin 43 -> 39 bytes secret/demo/wbaoftexas_db_pw.txt | Bin 47 -> 39 bytes secret/demo/wbaoftexas_db_root_pw.txt | Bin 52 -> 39 bytes strapi/docker-compose.yaml | 1 + strapi/playbook-demo.yaml | 2 +- 6 files changed, 2 insertions(+), 1 deletion(-) diff --git a/secret/demo/beoftexas.env b/secret/demo/beoftexas.env index 55077e65c7566f33a2d12095e975346431372085..ca3e865d66ca02819958a8f2d304782ea129e9ac 100644 GIT binary patch literal 477 zcmV<30V4hYM@dveQdv+`0OU6WK+S`y36FjmKQm>FYo;^N=FB_1Qu$HVRvNO({69!= zjJ!a#lZw8=sW+;zWT1`kyA^^4=x1>SXe~;k#W&i1g_C+DE3QJ&fw5=ep!2Mrfw@+1 zuv&Wwv-s_nN#yr5oU~_4@hxDqdA9ZAg_=i)Fg&3UU9ai1@IlX}&D9wNa9*s07XUwZ zEuFqsdwx1gcVl}v9q(P=tX6;_9cY!Qc9|_98l=}=V4Xa`8@szrEMZ9NL!9WtEwfq_ zC=K;%>2%ib{6BQ>$b%C4e~~4FM=6TWL2I3il@LsrcpVPiQSLS{^V7KgK{1Zj+}LSi z5pSI4bi~7|L8S9h`|Ot{6I@`9&9N;kCZ2g2(dav!B*~WfdD*_s(;ZXEgalnV0VM)T zlW%oXi%pL6o*X9{JcQ^;>VKIg&!5mS7jpm%=b|HZ2qkjp7na@ZD~ql!^7kB04bI^) z%y+P=opr)>Ng))F&GeB{92Q!Mqh=oSOveTny+9%S6W%b8-TZDItxflb;n?p{PM2!s z_P!-)oahziHWeG1z$6&b=oA{ukDj&CnhYdoB9VRh(t%fI@cLwfz@uylCW4vWabX#A T(X0G}co}mG6Y0zw!bJxX=TiD|Cb)uy|3$i-+1cyw@E2~iIOlf2 zX)K$>q-b3PLZ5eG~3?XlZqHJx)5z+@Jaw+)x#^Va9zP7PLbm<?oanZAAmCogmNU} z#>G))MliK>&%Lzg*wezn-2Rr<)X@9H311BmkTa*|Pv>ojvE!^>-m}HZ|4+Y!jR{4) PbW0b;(E&9INY$6f!zAa+ diff --git a/secret/demo/strapi_db_pw.txt b/secret/demo/strapi_db_pw.txt index 88e3335cd4091548c8d24104aadb6a3b70b2b171..8cb27bced4d829c3f6f87a3aaab8c0783921ae3b 100644 GIT binary patch literal 39 vcmZQ@_Y83kiVO&0P<>kPx6y;&@f#CGG`j Date: Wed, 21 Jan 2026 01:22:24 -0600 Subject: [PATCH 04/17] fix: allow Google CDN in CSP for jQuery Add ajax.googleapis.com to script-src in both prod and demo beoftexas nginx configs to allow loading jQuery from Google CDN. Co-Authored-By: Claude Opus 4.5 --- beoftexas/docker/nginx-demo/default.conf | 2 +- beoftexas/docker/nginx/default.conf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beoftexas/docker/nginx-demo/default.conf b/beoftexas/docker/nginx-demo/default.conf index 15fdb55..17f4665 100644 --- a/beoftexas/docker/nginx-demo/default.conf +++ b/beoftexas/docker/nginx-demo/default.conf @@ -11,7 +11,7 @@ server { add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Cross-Origin-Resource-Policy "same-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://demo-strapi.beoftexas.com;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://ajax.googleapis.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://demo-strapi.beoftexas.com;" always; # Serve static files directly # Note: add_header in location blocks overrides parent headers, so we repeat security headers here diff --git a/beoftexas/docker/nginx/default.conf b/beoftexas/docker/nginx/default.conf index 7c5b424..3baf0fe 100644 --- a/beoftexas/docker/nginx/default.conf +++ b/beoftexas/docker/nginx/default.conf @@ -11,7 +11,7 @@ server { add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Cross-Origin-Resource-Policy "same-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://strapi.beoftexas.com;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://ajax.googleapis.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://strapi.beoftexas.com;" always; # Serve static files directly # Note: add_header in location blocks overrides parent headers, so we repeat security headers here From a71d6de09db87c571f8ae5d47f3b496e002c15ed Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 01:24:03 -0600 Subject: [PATCH 05/17] fix: add Google CDN to connect-src for source maps --- beoftexas/docker/nginx-demo/default.conf | 2 +- beoftexas/docker/nginx/default.conf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beoftexas/docker/nginx-demo/default.conf b/beoftexas/docker/nginx-demo/default.conf index 17f4665..5b14a36 100644 --- a/beoftexas/docker/nginx-demo/default.conf +++ b/beoftexas/docker/nginx-demo/default.conf @@ -11,7 +11,7 @@ server { add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Cross-Origin-Resource-Policy "same-origin" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://ajax.googleapis.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://demo-strapi.beoftexas.com;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://ajax.googleapis.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://demo-strapi.beoftexas.com https://ajax.googleapis.com;" always; # Serve static files directly # Note: add_header in location blocks overrides parent headers, so we repeat security headers here diff --git a/beoftexas/docker/nginx/default.conf b/beoftexas/docker/nginx/default.conf index 3baf0fe..e103956 100644 --- a/beoftexas/docker/nginx/default.conf +++ b/beoftexas/docker/nginx/default.conf @@ -11,7 +11,7 @@ server { add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Cross-Origin-Resource-Policy "same-origin" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://ajax.googleapis.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://strapi.beoftexas.com;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://ajax.googleapis.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://strapi.beoftexas.com https://ajax.googleapis.com;" always; # Serve static files directly # Note: add_header in location blocks overrides parent headers, so we repeat security headers here From 4a859d5b206687457cbec6f7e44e321c6fc12cec Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 01:35:03 -0600 Subject: [PATCH 06/17] feat: add demo restore playbooks for strapi - strapi/restore-db-demo.yaml - restore postgres from S3 - strapi/restore-app-demo.yaml - restore app directory from S3 Co-Authored-By: Claude Opus 4.5 --- strapi/restore-app-demo.yaml | 140 ++++++++++++++++++++++++++++++++ strapi/restore-db-demo.yaml | 152 +++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 strapi/restore-app-demo.yaml create mode 100644 strapi/restore-db-demo.yaml diff --git a/strapi/restore-app-demo.yaml b/strapi/restore-app-demo.yaml new file mode 100644 index 0000000..fc2ff34 --- /dev/null +++ b/strapi/restore-app-demo.yaml @@ -0,0 +1,140 @@ +--- +- name: Restore Strapi app directory from S3 backup (Demo) + hosts: demo + gather_facts: false + become: true + vars: + deploy_path: /home/ubuntu/deploy/strapi + s3_access_key: "{{ lookup('env', 'CONTABO_S3_ACCESS_KEY') }}" + s3_secret_key: "{{ lookup('env', 'CONTABO_S3_SECRET_KEY') }}" + s3_bucket: "{{ lookup('env', 'CONTABO_S3_BUCKET') | default('beoftexas-backup', true) }}" + + tasks: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Check if AWS CLI is already installed + ansible.builtin.command: aws --version + register: aws_cli_check + changed_when: false + failed_when: false + + - name: Download AWS CLI v2 installer + ansible.builtin.get_url: + url: https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip + dest: /tmp/awscliv2.zip + mode: '0644' + when: aws_cli_check.rc != 0 + + - name: Unzip AWS CLI installer + ansible.builtin.unarchive: + src: /tmp/awscliv2.zip + dest: /tmp/ + remote_src: true + when: aws_cli_check.rc != 0 + + - name: Install AWS CLI v2 + ansible.builtin.command: /tmp/aws/install + when: aws_cli_check.rc != 0 + + - name: Clean up AWS CLI installer files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /tmp/awscliv2.zip + - /tmp/aws + when: aws_cli_check.rc != 0 + + - name: Download Strapi app backup from Contabo S3 + ansible.builtin.shell: | + AWS_ACCESS_KEY_ID="{{ s3_access_key }}" \ + AWS_SECRET_ACCESS_KEY="{{ s3_secret_key }}" \ + aws s3 cp s3://{{ s3_bucket }}/strapi_app_latest.tar.gz /tmp/strapi_app_latest.tar.gz \ + --endpoint-url https://usc1.contabostorage.com + no_log: false + changed_when: true + + - name: Verify backup file was downloaded + ansible.builtin.stat: + path: /tmp/strapi_app_latest.tar.gz + register: backup_file + failed_when: not backup_file.stat.exists or backup_file.stat.size == 0 + + - name: Display backup file info + ansible.builtin.debug: + msg: "Backup file downloaded: {{ backup_file.stat.size }} bytes" + + - name: Stop Strapi service + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + export DATABASE_PASSWORD=$(cat secrets/strapi_db_pw.txt) && \ + docker compose stop web + register: stop_result + changed_when: true + + - name: Display stop result + ansible.builtin.debug: + msg: "Strapi service stopped" + + - name: Backup existing app directory (if exists) + ansible.builtin.shell: | + if [ -d "{{ deploy_path }}/app" ]; then + mv {{ deploy_path }}/app {{ deploy_path }}/app.backup.$(date +%Y%m%d_%H%M%S) + fi + args: + executable: /bin/bash + changed_when: true + + - name: Test backup file integrity + ansible.builtin.shell: tar -tzf /tmp/strapi_app_latest.tar.gz > /dev/null + register: integrity_check + changed_when: false + failed_when: integrity_check.rc != 0 + + - name: Extract Strapi app directory from backup + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + tar -xzf /tmp/strapi_app_latest.tar.gz + register: extract_result + changed_when: true + + - name: Display extraction completion + ansible.builtin.debug: + msg: "Strapi app directory extracted successfully" + + - name: Verify app directory exists + ansible.builtin.stat: + path: "{{ deploy_path }}/app" + register: app_dir + failed_when: not app_dir.stat.exists or not app_dir.stat.isdir + + - name: Display app directory status + ansible.builtin.debug: + msg: "App directory verified at {{ deploy_path }}/app" + + - name: Restart Strapi service + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + export DATABASE_PASSWORD=$(cat secrets/strapi_db_pw.txt) && \ + docker compose start web + register: start_result + changed_when: true + + - name: Display restart status + ansible.builtin.debug: + msg: "Strapi service restarted successfully" + + - name: Clean up temporary backup file + ansible.builtin.file: + path: /tmp/strapi_app_latest.tar.gz + state: absent + + - name: Display final status + ansible.builtin.debug: + msg: + - "=====================================" + - "Strapi app restore completed successfully!" + - "=====================================" diff --git a/strapi/restore-db-demo.yaml b/strapi/restore-db-demo.yaml new file mode 100644 index 0000000..78290b5 --- /dev/null +++ b/strapi/restore-db-demo.yaml @@ -0,0 +1,152 @@ +--- +- name: Restore strapi postgres database from S3 backup (Demo) + hosts: demo + gather_facts: false + become: true + vars: + deploy_path: /home/ubuntu/deploy/strapi + s3_access_key: "{{ lookup('env', 'CONTABO_S3_ACCESS_KEY') }}" + s3_secret_key: "{{ lookup('env', 'CONTABO_S3_SECRET_KEY') }}" + s3_bucket: "{{ lookup('env', 'CONTABO_S3_BUCKET') | default('beoftexas-backup', true) }}" + db_pw_file: "{{ deploy_path }}/secrets/strapi_db_pw.txt" + + tasks: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install unzip utility + ansible.builtin.apt: + name: unzip + state: present + + - name: Check if AWS CLI is already installed + ansible.builtin.command: aws --version + register: aws_cli_check + changed_when: false + failed_when: false + + - name: Download AWS CLI v2 installer + ansible.builtin.get_url: + url: https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip + dest: /tmp/awscliv2.zip + mode: '0644' + when: aws_cli_check.rc != 0 + + - name: Unzip AWS CLI installer + ansible.builtin.unarchive: + src: /tmp/awscliv2.zip + dest: /tmp/ + remote_src: true + when: aws_cli_check.rc != 0 + + - name: Install AWS CLI v2 + ansible.builtin.command: /tmp/aws/install + when: aws_cli_check.rc != 0 + + - name: Clean up AWS CLI installer files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /tmp/awscliv2.zip + - /tmp/aws + when: aws_cli_check.rc != 0 + + - name: Download strapi database backup from Contabo S3 + ansible.builtin.shell: | + AWS_ACCESS_KEY_ID="{{ s3_access_key }}" \ + AWS_SECRET_ACCESS_KEY="{{ s3_secret_key }}" \ + aws s3 cp s3://{{ s3_bucket }}/strapi_latest.sql.gz /tmp/strapi_postgres_latest.sql.gz \ + --endpoint-url https://usc1.contabostorage.com + no_log: false + changed_when: true + + - name: Verify backup file was downloaded + ansible.builtin.stat: + path: /tmp/strapi_postgres_latest.sql.gz + register: backup_file + failed_when: not backup_file.stat.exists or backup_file.stat.size == 0 + + - name: Display backup file info + ansible.builtin.debug: + msg: "Backup file downloaded: {{ backup_file.stat.size }} bytes" + + - name: Decompress backup file + ansible.builtin.shell: gunzip -f /tmp/strapi_postgres_latest.sql.gz + register: decompress_result + changed_when: true + + - name: Verify decompressed file exists + ansible.builtin.stat: + path: /tmp/strapi_postgres_latest.sql + register: sql_file + failed_when: not sql_file.stat.exists + + - name: Display SQL file info + ansible.builtin.debug: + msg: "Decompressed SQL file: {{ sql_file.stat.size }} bytes" + + - name: Wait for PostgreSQL to be ready + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + PGPASSWORD=$(cat {{ db_pw_file }}) docker compose exec -T postgres psql -U strapi -d strapi -c "SELECT 1" 2>&1 + register: db_ready + until: db_ready.rc == 0 + retries: 30 + delay: 2 + changed_when: false + no_log: false + + - name: Drop existing strapi database + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + PGPASSWORD=$(cat {{ db_pw_file }}) docker compose exec -T postgres psql -U strapi -d postgres -c "DROP DATABASE IF EXISTS strapi;" + register: drop_result + changed_when: true + no_log: true + + - name: Create fresh strapi database + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + PGPASSWORD=$(cat {{ db_pw_file }}) docker compose exec -T postgres psql -U strapi -d postgres -c "CREATE DATABASE strapi;" + register: create_result + changed_when: true + no_log: true + + - name: Restore strapi database from backup + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + PGPASSWORD=$(cat {{ db_pw_file }}) docker compose exec -T postgres psql -U strapi -d strapi < /tmp/strapi_postgres_latest.sql + register: restore_result + changed_when: true + no_log: true + + - name: Display restore completion + ansible.builtin.debug: + msg: "Database restored successfully" + + - name: Verify database contains data + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + PGPASSWORD=$(cat {{ db_pw_file }}) docker compose exec -T postgres psql -U strapi -d strapi -c "\dt" 2>&1 + register: table_list + changed_when: false + no_log: false + + - name: Display database tables + ansible.builtin.debug: + msg: "{{ table_list.stdout_lines }}" + + - name: Clean up temporary SQL file + ansible.builtin.file: + path: /tmp/strapi_postgres_latest.sql + state: absent + + - name: Display final status + ansible.builtin.debug: + msg: + - "=====================================" + - "Strapi database restore completed!" + - "=====================================" From 4e0d30c2c6a259299b1832f78864bc0f7e254deb Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 02:03:27 -0600 Subject: [PATCH 07/17] feat: add beoftexas restore-db-demo.yaml playbook --- beoftexas/restore-db-demo.yaml | 174 +++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 beoftexas/restore-db-demo.yaml diff --git a/beoftexas/restore-db-demo.yaml b/beoftexas/restore-db-demo.yaml new file mode 100644 index 0000000..7d9a4c0 --- /dev/null +++ b/beoftexas/restore-db-demo.yaml @@ -0,0 +1,174 @@ +--- +- name: Force restore beoftexas database from backup (Demo) + hosts: demo + gather_facts: false + become: true + vars: + deploy_path: /home/ubuntu/deploy/beoftexas + s3_access_key: "{{ lookup('env', 'CONTABO_S3_ACCESS_KEY') }}" + s3_secret_key: "{{ lookup('env', 'CONTABO_S3_SECRET_KEY') }}" + s3_bucket: "{{ lookup('env', 'CONTABO_S3_BUCKET') | default('beoftexas-backup', true) }}" + env_file: "{{ deploy_path }}/.env" + + tasks: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: Install unzip utility + ansible.builtin.apt: + name: unzip + state: present + + - name: Check if AWS CLI is already installed + ansible.builtin.command: aws --version + register: aws_cli_check + changed_when: false + failed_when: false + + - name: Download AWS CLI v2 installer + ansible.builtin.get_url: + url: https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip + dest: /tmp/awscliv2.zip + mode: '0644' + when: aws_cli_check.rc != 0 + + - name: Unzip AWS CLI installer + ansible.builtin.unarchive: + src: /tmp/awscliv2.zip + dest: /tmp/ + remote_src: true + when: aws_cli_check.rc != 0 + + - name: Install AWS CLI v2 + ansible.builtin.command: /tmp/aws/install + when: aws_cli_check.rc != 0 + + - name: Clean up AWS CLI installer files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /tmp/awscliv2.zip + - /tmp/aws + when: aws_cli_check.rc != 0 + + - name: Download database backup from Contabo S3 + ansible.builtin.shell: | + AWS_ACCESS_KEY_ID="{{ s3_access_key }}" \ + AWS_SECRET_ACCESS_KEY="{{ s3_secret_key }}" \ + aws s3 cp s3://{{ s3_bucket }}/beoftexas_latest.sql.gz /tmp/beoftexas_latest.sql.gz \ + --endpoint-url https://usc1.contabostorage.com + no_log: false + changed_when: true + + - name: Verify backup file was downloaded + ansible.builtin.stat: + path: /tmp/beoftexas_latest.sql.gz + register: backup_file + failed_when: not backup_file.stat.exists or backup_file.stat.size == 0 + + - name: Display backup file info + ansible.builtin.debug: + msg: "Backup file downloaded: {{ backup_file.stat.size }} bytes" + + - name: Stop beoftexas application service + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + docker compose stop web + register: stop_result + changed_when: true + + - name: Display stop result + ansible.builtin.debug: + msg: "Beoftexas service stopped" + + - name: Wait for MariaDB to be ready + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + docker compose exec -T mariadb mariadb -u root -p$(grep "^DB_ROOT_PASSWORD=" {{ env_file }} | cut -d'=' -f2-) -e "SELECT 1" 2>&1 + register: db_ready + until: db_ready.rc == 0 + retries: 30 + delay: 2 + changed_when: false + no_log: false + + - name: Drop existing beoftexas database + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + docker compose exec -T mariadb mariadb -u root -p$(grep "^DB_ROOT_PASSWORD=" {{ env_file }} | cut -d'=' -f2-) -e "DROP DATABASE IF EXISTS beoftexas;" + register: drop_result + changed_when: true + no_log: true + + - name: Display database drop status + ansible.builtin.debug: + msg: "Database dropped successfully" + + - name: Create fresh beoftexas database + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + docker compose exec -T mariadb mariadb -u root -p$(grep "^DB_ROOT_PASSWORD=" {{ env_file }} | cut -d'=' -f2-) -e "CREATE DATABASE beoftexas CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + register: create_result + changed_when: true + no_log: true + + - name: Display database creation status + ansible.builtin.debug: + msg: "Database created successfully" + + - name: Test backup file integrity + ansible.builtin.shell: gunzip -t /tmp/beoftexas_latest.sql.gz + register: integrity_check + changed_when: false + failed_when: integrity_check.rc != 0 + + - name: Restore database from backup + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + gunzip -c /tmp/beoftexas_latest.sql.gz | docker compose exec -T mariadb mariadb -u root -p$(grep "^DB_ROOT_PASSWORD=" {{ env_file }} | cut -d'=' -f2-) beoftexas + register: restore_result + changed_when: true + no_log: false + + - name: Display restore completion + ansible.builtin.debug: + msg: "Database restoration completed" + + - name: Verify database has tables after restore + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + docker compose exec -T mariadb mariadb -u root -p$(grep "^DB_ROOT_PASSWORD=" {{ env_file }} | cut -d'=' -f2-) beoftexas -e "SHOW TABLES;" 2>/dev/null | wc -l + register: table_count + changed_when: false + no_log: true + failed_when: (table_count.stdout | int) <= 1 + + - name: Display table count + ansible.builtin.debug: + msg: "Database restored successfully with {{ (table_count.stdout | int) - 1 }} tables" + + - name: Restart beoftexas application service + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + docker compose start web + register: start_result + changed_when: true + + - name: Display restart status + ansible.builtin.debug: + msg: "Beoftexas service restarted successfully" + + - name: Clean up temporary backup file + ansible.builtin.file: + path: /tmp/beoftexas_latest.sql.gz + state: absent + + - name: Display final status + ansible.builtin.debug: + msg: + - "=====================================" + - "Database restore completed successfully!" + - "=====================================" From bcbd3331270d9bc340073817fe03e80788bb0cd8 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 02:06:58 -0600 Subject: [PATCH 08/17] chore: update beoftexas demo to 2026.01.21.30d8b812 --- beoftexas/docker-compose-demo.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beoftexas/docker-compose-demo.yaml b/beoftexas/docker-compose-demo.yaml index 2dba101..cc2fb0e 100644 --- a/beoftexas/docker-compose-demo.yaml +++ b/beoftexas/docker-compose-demo.yaml @@ -15,7 +15,7 @@ services: restart: always web: - image: casmith/beoftexas:2026.01.20.919c851e + image: casmith/beoftexas:2026.01.21.30d8b812 env_file: - .env networks: From 55c5fa817eb083bead175302759fc9ef75c2dc43 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 02:18:47 -0600 Subject: [PATCH 09/17] feat: make demo deployment fully repeatable - Add S3 credential checks to deploy-demo.sh prerequisites - Add Strapi database and app restore steps to deploy-demo.sh - Add beoftexas database restore step to deploy-demo.sh - Clear beoftexas public_files volume before deployment for clean assets - Add teardown-demo.sh script to cleanly tear down demo environment Co-Authored-By: Claude Opus 4.5 --- deploy-demo.sh | 65 ++++++++++++++++++++++++++++++++--------- teardown-demo.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 13 deletions(-) create mode 100755 teardown-demo.sh diff --git a/deploy-demo.sh b/deploy-demo.sh index 63a0b77..1ea7877 100755 --- a/deploy-demo.sh +++ b/deploy-demo.sh @@ -38,6 +38,11 @@ run_playbook() { fi } +# Function to run SSH command on demo server +run_ssh() { + ssh -o BatchMode=yes ubuntu@$SERVER_IP "$@" +} + # Check prerequisites echo -e "${YELLOW}>>> Checking prerequisites...${NC}" @@ -54,6 +59,15 @@ if grep -q "YOUR_CLOUDFLARE_TUNNEL_TOKEN_HERE" secret/demo/cloudflare_tunnel_tok exit 1 fi +# Check S3 credentials for database restores +if [ -z "$CONTABO_S3_ACCESS_KEY" ] || [ -z "$CONTABO_S3_SECRET_KEY" ]; then + echo -e "${RED}ERROR: S3 credentials not set${NC}" + echo "Please set environment variables:" + echo " export CONTABO_S3_ACCESS_KEY=your_access_key" + echo " export CONTABO_S3_SECRET_KEY=your_secret_key" + exit 1 +fi + echo -e "${GREEN}✓ Prerequisites check passed${NC}" echo "" @@ -70,23 +84,53 @@ else echo "" fi -# Now run all deployment playbooks in order +# Deploy infrastructure run_playbook "install-docker.yaml" "Step 2: Install Docker" + +# Deploy Strapi run_playbook "strapi/playbook-demo.yaml" "Step 3: Deploy Strapi (PostgreSQL CMS)" -run_playbook "beoftexas/playbook-demo.yaml" "Step 4: Deploy Benefit Elect of Texas" -run_playbook "wbaoftexas/playbook-demo.yaml" "Step 5: Deploy WBA of Texas" -run_playbook "nginx-demo/playbook.yaml" "Step 6: Deploy Nginx (reverse proxy + SSL certs)" -run_playbook "cloudflared/playbook.yaml" "Step 7: Deploy Cloudflare Tunnel" + +# Restore Strapi data +run_playbook "strapi/restore-app-demo.yaml" "Step 4: Restore Strapi app directory" +run_playbook "strapi/restore-db-demo.yaml" "Step 5: Restore Strapi database" + +# Restart Strapi to pick up restored data +echo -e "${YELLOW}>>> Step 6: Restart Strapi${NC}" +run_ssh "cd /home/ubuntu/deploy/strapi && export DATABASE_PASSWORD=\$(cat secrets/strapi_db_pw.txt) && docker compose restart web" +echo -e "${GREEN}✓ Strapi restarted${NC}" +echo "" + +# Clear beoftexas public_files volume if it exists (for clean asset deployment) +echo -e "${YELLOW}>>> Step 7: Prepare beoftexas deployment${NC}" +run_ssh "sudo docker volume rm beoftexas_public_files 2>/dev/null || true" +echo -e "${GREEN}✓ beoftexas volume cleared${NC}" +echo "" + +# Deploy beoftexas +run_playbook "beoftexas/playbook-demo.yaml" "Step 8: Deploy Benefit Elect of Texas" + +# Restore beoftexas database +run_playbook "beoftexas/restore-db-demo.yaml" "Step 9: Restore beoftexas database" + +# Deploy wbaoftexas +run_playbook "wbaoftexas/playbook-demo.yaml" "Step 10: Deploy WBA of Texas" + +# Note: wbaoftexas restore playbook would go here if needed +# run_playbook "wbaoftexas/restore-db-demo.yaml" "Step 11: Restore wbaoftexas database" + +# Deploy nginx and cloudflared +run_playbook "nginx-demo/playbook.yaml" "Step 11: Deploy Nginx (reverse proxy + SSL certs)" +run_playbook "cloudflared/playbook.yaml" "Step 12: Deploy Cloudflare Tunnel" echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}Demo Deployment Complete!${NC}" echo -e "${GREEN}========================================${NC}" echo "" -echo "All services deployed successfully!" +echo "All services deployed and databases restored!" echo "" echo "Deployed services:" -echo " ✓ Strapi CMS (PostgreSQL)" -echo " ✓ Benefit Elect of Texas (MariaDB)" +echo " ✓ Strapi CMS (PostgreSQL) - restored from backup" +echo " ✓ Benefit Elect of Texas (MariaDB) - restored from backup" echo " ✓ WBA of Texas (MariaDB)" echo " ✓ Nginx reverse proxy with Let's Encrypt SSL" echo " ✓ Cloudflare Tunnel" @@ -102,8 +146,3 @@ echo " 2. Check cloudflared logs: docker logs cloudflared-tunnel-1" echo " 3. Verify certs: ls /home/ubuntu/deploy/nginx-demo/data/certbot/conf/live/" echo " 4. Test URLs in browser" echo "" -echo "To restore databases from production backups:" -echo " ansible-playbook -i $INVENTORY beoftexas/restore-db.yaml" -echo " ansible-playbook -i $INVENTORY wbaoftexas/restore-db.yaml" -echo " ansible-playbook -i $INVENTORY strapi/restore-db.yaml" -echo "" diff --git a/teardown-demo.sh b/teardown-demo.sh new file mode 100755 index 0000000..26a26b5 --- /dev/null +++ b/teardown-demo.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +INVENTORY="hosts-demo.yaml" + +# Get server IP from inventory or use default +SERVER_IP="${1:-$(grep 'ansible_host:' $INVENTORY | head -1 | awk '{print $2}')}" + +echo -e "${RED}========================================${NC}" +echo -e "${RED}Demo Environment Teardown Script${NC}" +echo -e "${RED}========================================${NC}" +echo "" +echo "Target server: $SERVER_IP" +echo "" + +# Confirm teardown +read -p "Are you sure you want to tear down the demo environment? (yes/no): " confirm +if [ "$confirm" != "yes" ]; then + echo "Teardown cancelled." + exit 0 +fi + +echo "" + +# Function to run SSH command on demo server +run_ssh() { + ssh -o BatchMode=yes ubuntu@$SERVER_IP "$@" +} + +echo -e "${YELLOW}>>> Stopping and removing all demo containers...${NC}" + +# Stop and remove each service +for service in cloudflared nginx-demo beoftexas wbaoftexas strapi; do + echo " Tearing down $service..." + run_ssh "cd /home/ubuntu/deploy/$service 2>/dev/null && sudo docker compose down -v 2>/dev/null || true" +done + +echo -e "${GREEN}✓ All containers stopped and removed${NC}" +echo "" + +echo -e "${YELLOW}>>> Removing Docker volumes...${NC}" +run_ssh "sudo docker volume prune -f 2>/dev/null || true" +echo -e "${GREEN}✓ Volumes removed${NC}" +echo "" + +echo -e "${YELLOW}>>> Removing deploy directories...${NC}" +run_ssh "sudo rm -rf /home/ubuntu/deploy/*" +echo -e "${GREEN}✓ Deploy directories removed${NC}" +echo "" + +echo -e "${YELLOW}>>> Pruning Docker system...${NC}" +run_ssh "sudo docker system prune -af 2>/dev/null || true" +echo -e "${GREEN}✓ Docker system pruned${NC}" +echo "" + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Demo Environment Teardown Complete!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo "The demo VM is now clean and ready for a fresh deployment." +echo "" +echo "To redeploy, run:" +echo " export CONTABO_S3_ACCESS_KEY=your_key" +echo " export CONTABO_S3_SECRET_KEY=your_secret" +echo " ./deploy-demo.sh" +echo "" +echo "Note: Cloudflare Tunnel and DNS records are preserved." +echo " SSL certificates will be re-issued on next deploy." +echo "" From 0000659d3eb4eac8bc17de58ddd45248e52e7790 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 02:26:12 -0600 Subject: [PATCH 10/17] fix: stop Strapi before database restore - Stop Strapi web service before dropping database - Terminate existing database connections before drop - Connect to postgres database (not strapi) when waiting for db ready - Restart Strapi service after restore completes Co-Authored-By: Claude Opus 4.5 --- strapi/restore-db-demo.yaml | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/strapi/restore-db-demo.yaml b/strapi/restore-db-demo.yaml index 78290b5..cf422f1 100644 --- a/strapi/restore-db-demo.yaml +++ b/strapi/restore-db-demo.yaml @@ -88,10 +88,22 @@ ansible.builtin.debug: msg: "Decompressed SQL file: {{ sql_file.stat.size }} bytes" + - name: Stop Strapi service + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + export DATABASE_PASSWORD=$(cat {{ db_pw_file }}) && \ + docker compose stop web + register: stop_result + changed_when: true + + - name: Display stop result + ansible.builtin.debug: + msg: "Strapi service stopped" + - name: Wait for PostgreSQL to be ready ansible.builtin.shell: | cd {{ deploy_path }} && \ - PGPASSWORD=$(cat {{ db_pw_file }}) docker compose exec -T postgres psql -U strapi -d strapi -c "SELECT 1" 2>&1 + PGPASSWORD=$(cat {{ db_pw_file }}) docker compose exec -T postgres psql -U strapi -d postgres -c "SELECT 1" 2>&1 register: db_ready until: db_ready.rc == 0 retries: 30 @@ -99,6 +111,14 @@ changed_when: false no_log: false + - name: Terminate existing connections to strapi database + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + PGPASSWORD=$(cat {{ db_pw_file }}) docker compose exec -T postgres psql -U strapi -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'strapi' AND pid <> pg_backend_pid();" + register: terminate_result + changed_when: true + failed_when: false + - name: Drop existing strapi database ansible.builtin.shell: | cd {{ deploy_path }} && \ @@ -144,6 +164,18 @@ path: /tmp/strapi_postgres_latest.sql state: absent + - name: Restart Strapi service + ansible.builtin.shell: | + cd {{ deploy_path }} && \ + export DATABASE_PASSWORD=$(cat {{ db_pw_file }}) && \ + docker compose start web + register: start_result + changed_when: true + + - name: Display restart status + ansible.builtin.debug: + msg: "Strapi service restarted successfully" + - name: Display final status ansible.builtin.debug: msg: From 67f682e950dfd619a4860074c00f107100148d3d Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 02:30:01 -0600 Subject: [PATCH 11/17] fix: use LKG strapi backup (latest is corrupted) strapi_latest.sql.gz is corrupted (see issue #9). Using strapi_20260109T231005.sql.gz until the backup job is fixed. Co-Authored-By: Claude Opus 4.5 --- strapi/restore-db-demo.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/strapi/restore-db-demo.yaml b/strapi/restore-db-demo.yaml index cf422f1..b67f525 100644 --- a/strapi/restore-db-demo.yaml +++ b/strapi/restore-db-demo.yaml @@ -54,11 +54,13 @@ - /tmp/aws when: aws_cli_check.rc != 0 + # NOTE: strapi_latest.sql.gz is corrupted (see GitHub issue #9) + # Using last known good backup from 2026-01-09 until backup job is fixed - name: Download strapi database backup from Contabo S3 ansible.builtin.shell: | AWS_ACCESS_KEY_ID="{{ s3_access_key }}" \ AWS_SECRET_ACCESS_KEY="{{ s3_secret_key }}" \ - aws s3 cp s3://{{ s3_bucket }}/strapi_latest.sql.gz /tmp/strapi_postgres_latest.sql.gz \ + aws s3 cp s3://{{ s3_bucket }}/strapi_20260109T231005.sql.gz /tmp/strapi_postgres_latest.sql.gz \ --endpoint-url https://usc1.contabostorage.com no_log: false changed_when: true From 4ea9e31f021ca394d5105cb72f1386a0262ac378 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 02:44:08 -0600 Subject: [PATCH 12/17] fix: run beoftexas migrations after database restore Migrations require existing tables, so they must run after the database is restored from backup, not on an empty database. - Remove migration step from beoftexas/playbook-demo.yaml - Add migration step to deploy-demo.sh after restore Co-Authored-By: Claude Opus 4.5 --- beoftexas/playbook-demo.yaml | 11 ++--------- deploy-demo.sh | 14 ++++++++++---- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/beoftexas/playbook-demo.yaml b/beoftexas/playbook-demo.yaml index 3c87cd1..0812b51 100644 --- a/beoftexas/playbook-demo.yaml +++ b/beoftexas/playbook-demo.yaml @@ -69,15 +69,8 @@ delay: 2 until: db_check.rc == 0 - - name: Run database migrations - ansible.builtin.shell: - cmd: "docker compose exec -T web php vendor/bin/phinx migrate -e production" - chdir: "{{ deploy_path }}/beoftexas/" - register: migration_result - - - name: Display migration output - ansible.builtin.debug: - var: migration_result.stdout_lines + # NOTE: Migrations are run after database restore in deploy-demo.sh + # Do not run migrations here on empty database - name: Restart reverse proxy nginx to pick up beoftexas on shared network ansible.builtin.shell: diff --git a/deploy-demo.sh b/deploy-demo.sh index 1ea7877..79178ff 100755 --- a/deploy-demo.sh +++ b/deploy-demo.sh @@ -112,15 +112,21 @@ run_playbook "beoftexas/playbook-demo.yaml" "Step 8: Deploy Benefit Elect of Tex # Restore beoftexas database run_playbook "beoftexas/restore-db-demo.yaml" "Step 9: Restore beoftexas database" +# Run beoftexas migrations (after restore) +echo -e "${YELLOW}>>> Step 10: Run beoftexas database migrations${NC}" +run_ssh "cd /home/ubuntu/deploy/beoftexas && docker compose exec -T web php vendor/bin/phinx migrate -e production" +echo -e "${GREEN}✓ beoftexas migrations completed${NC}" +echo "" + # Deploy wbaoftexas -run_playbook "wbaoftexas/playbook-demo.yaml" "Step 10: Deploy WBA of Texas" +run_playbook "wbaoftexas/playbook-demo.yaml" "Step 11: Deploy WBA of Texas" # Note: wbaoftexas restore playbook would go here if needed -# run_playbook "wbaoftexas/restore-db-demo.yaml" "Step 11: Restore wbaoftexas database" +# run_playbook "wbaoftexas/restore-db-demo.yaml" "Step 12: Restore wbaoftexas database" # Deploy nginx and cloudflared -run_playbook "nginx-demo/playbook.yaml" "Step 11: Deploy Nginx (reverse proxy + SSL certs)" -run_playbook "cloudflared/playbook.yaml" "Step 12: Deploy Cloudflare Tunnel" +run_playbook "nginx-demo/playbook.yaml" "Step 12: Deploy Nginx (reverse proxy + SSL certs)" +run_playbook "cloudflared/playbook.yaml" "Step 13: Deploy Cloudflare Tunnel" echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}Demo Deployment Complete!${NC}" From 1850554d5d5dddd6ddb1a17361ce400d28fe958b Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 02:49:41 -0600 Subject: [PATCH 13/17] fix: add sudo to docker compose commands in deploy-demo.sh The .env and secrets files are owned by root (from ansible become: true) so docker compose commands need sudo to read them. Co-Authored-By: Claude Opus 4.5 --- deploy-demo.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy-demo.sh b/deploy-demo.sh index 79178ff..056f29e 100755 --- a/deploy-demo.sh +++ b/deploy-demo.sh @@ -96,7 +96,7 @@ run_playbook "strapi/restore-db-demo.yaml" "Step 5: Restore Strapi database" # Restart Strapi to pick up restored data echo -e "${YELLOW}>>> Step 6: Restart Strapi${NC}" -run_ssh "cd /home/ubuntu/deploy/strapi && export DATABASE_PASSWORD=\$(cat secrets/strapi_db_pw.txt) && docker compose restart web" +run_ssh "cd /home/ubuntu/deploy/strapi && sudo bash -c 'export DATABASE_PASSWORD=\$(cat secrets/strapi_db_pw.txt) && docker compose restart web'" echo -e "${GREEN}✓ Strapi restarted${NC}" echo "" @@ -114,7 +114,7 @@ run_playbook "beoftexas/restore-db-demo.yaml" "Step 9: Restore beoftexas databas # Run beoftexas migrations (after restore) echo -e "${YELLOW}>>> Step 10: Run beoftexas database migrations${NC}" -run_ssh "cd /home/ubuntu/deploy/beoftexas && docker compose exec -T web php vendor/bin/phinx migrate -e production" +run_ssh "cd /home/ubuntu/deploy/beoftexas && sudo docker compose exec -T web php vendor/bin/phinx migrate -e production" echo -e "${GREEN}✓ beoftexas migrations completed${NC}" echo "" From 21c955d8d7176e42e6f7d38cd29aa046ce6f6e83 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 02:51:18 -0600 Subject: [PATCH 14/17] fix: set ubuntu ownership on deployed files Add owner/group: ubuntu to all file and copy tasks in demo playbooks so that docker compose commands can read .env and secrets files without needing sudo. Co-Authored-By: Claude Opus 4.5 --- beoftexas/playbook-demo.yaml | 8 ++++++++ deploy-demo.sh | 4 ++-- strapi/playbook-demo.yaml | 7 +++++++ wbaoftexas/playbook-demo.yaml | 7 +++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/beoftexas/playbook-demo.yaml b/beoftexas/playbook-demo.yaml index 0812b51..adff81c 100644 --- a/beoftexas/playbook-demo.yaml +++ b/beoftexas/playbook-demo.yaml @@ -11,6 +11,8 @@ ansible.builtin.file: path: "{{ item }}" state: directory + owner: ubuntu + group: ubuntu loop: - "{{ deploy_path }}" - "{{ deploy_path }}/beoftexas" @@ -26,17 +28,23 @@ copy: src: "docker-compose-demo.yaml" dest: "{{ deploy_path }}/beoftexas/docker-compose.yaml" + owner: ubuntu + group: ubuntu - name: copy nginx config (demo version with updated CSP) copy: src: "docker/nginx-demo/default.conf" dest: "{{ deploy_path }}/beoftexas/docker/nginx/default.conf" + owner: ubuntu + group: ubuntu - name: copy environment file copy: src: "../secret/demo/beoftexas.env" dest: "{{ deploy_path }}/beoftexas/.env" mode: '0600' + owner: ubuntu + group: ubuntu - name: Deploy with docker compose ansible.builtin.shell: diff --git a/deploy-demo.sh b/deploy-demo.sh index 056f29e..79178ff 100755 --- a/deploy-demo.sh +++ b/deploy-demo.sh @@ -96,7 +96,7 @@ run_playbook "strapi/restore-db-demo.yaml" "Step 5: Restore Strapi database" # Restart Strapi to pick up restored data echo -e "${YELLOW}>>> Step 6: Restart Strapi${NC}" -run_ssh "cd /home/ubuntu/deploy/strapi && sudo bash -c 'export DATABASE_PASSWORD=\$(cat secrets/strapi_db_pw.txt) && docker compose restart web'" +run_ssh "cd /home/ubuntu/deploy/strapi && export DATABASE_PASSWORD=\$(cat secrets/strapi_db_pw.txt) && docker compose restart web" echo -e "${GREEN}✓ Strapi restarted${NC}" echo "" @@ -114,7 +114,7 @@ run_playbook "beoftexas/restore-db-demo.yaml" "Step 9: Restore beoftexas databas # Run beoftexas migrations (after restore) echo -e "${YELLOW}>>> Step 10: Run beoftexas database migrations${NC}" -run_ssh "cd /home/ubuntu/deploy/beoftexas && sudo docker compose exec -T web php vendor/bin/phinx migrate -e production" +run_ssh "cd /home/ubuntu/deploy/beoftexas && docker compose exec -T web php vendor/bin/phinx migrate -e production" echo -e "${GREEN}✓ beoftexas migrations completed${NC}" echo "" diff --git a/strapi/playbook-demo.yaml b/strapi/playbook-demo.yaml index faef2bd..de071fa 100644 --- a/strapi/playbook-demo.yaml +++ b/strapi/playbook-demo.yaml @@ -11,6 +11,8 @@ ansible.builtin.file: path: "{{ item }}" state: directory + owner: ubuntu + group: ubuntu loop: - "{{ deploy_path }}" - "{{ deploy_path }}/strapi" @@ -26,6 +28,8 @@ copy: src: "{{ item }}" dest: "{{ deploy_path }}/strapi/{{ item }}" + owner: ubuntu + group: ubuntu loop: - docker-compose.yaml @@ -33,6 +37,9 @@ copy: src: "../secret/demo/{{ item }}" dest: "{{ deploy_path }}/strapi/secrets/{{ item }}" + owner: ubuntu + group: ubuntu + mode: '0600' loop: - strapi_db_pw.txt diff --git a/wbaoftexas/playbook-demo.yaml b/wbaoftexas/playbook-demo.yaml index 0a4e4c3..172d96c 100644 --- a/wbaoftexas/playbook-demo.yaml +++ b/wbaoftexas/playbook-demo.yaml @@ -11,6 +11,8 @@ ansible.builtin.file: path: "{{ item }}" state: directory + owner: ubuntu + group: ubuntu loop: - "{{ deploy_path }}" - "{{ deploy_path }}/wbaoftexas" @@ -27,6 +29,8 @@ copy: src: "{{ item }}" dest: "{{ deploy_path }}/wbaoftexas/{{ item }}" + owner: ubuntu + group: ubuntu loop: - docker-compose.yaml @@ -34,6 +38,9 @@ copy: src: "../secret/demo/{{ item }}" dest: "{{ deploy_path }}/wbaoftexas/secrets/{{ item }}" + owner: ubuntu + group: ubuntu + mode: '0600' loop: - wbaoftexas_db_pw.txt - wbaoftexas_db_root_pw.txt From 7838ffe937fd123710e2f2e6da6459a19ba5e3d6 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 03:02:39 -0600 Subject: [PATCH 15/17] fix: increase DNS propagation wait and add ubuntu ownership - Increase certbot DNS propagation wait from 30s to 60s - Add owner/group: ubuntu to nginx-demo and cloudflared playbooks Co-Authored-By: Claude Opus 4.5 --- cloudflared/playbook.yaml | 6 ++++++ nginx-demo/playbook.yaml | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cloudflared/playbook.yaml b/cloudflared/playbook.yaml index 37b9e58..a01aff0 100644 --- a/cloudflared/playbook.yaml +++ b/cloudflared/playbook.yaml @@ -7,6 +7,8 @@ path: "{{ item }}" state: directory mode: "0755" + owner: ubuntu + group: ubuntu loop: - "{{ deploy_path }}/cloudflared" - "{{ deploy_path }}/cloudflared/secrets" @@ -21,6 +23,8 @@ src: "{{ item }}" dest: "{{ deploy_path }}/cloudflared/{{ item }}" mode: "0644" + owner: ubuntu + group: ubuntu loop: - docker-compose.yaml @@ -29,6 +33,8 @@ src: "../secret/demo/cloudflare_tunnel_token.txt" dest: "{{ deploy_path }}/cloudflared/secrets/cloudflare_tunnel_token.txt" mode: "0600" + owner: ubuntu + group: ubuntu - name: Deploy with docker compose ansible.builtin.shell: diff --git a/nginx-demo/playbook.yaml b/nginx-demo/playbook.yaml index a2b5de6..5170d91 100644 --- a/nginx-demo/playbook.yaml +++ b/nginx-demo/playbook.yaml @@ -13,6 +13,8 @@ path: "{{ item }}" state: directory mode: "0755" + owner: ubuntu + group: ubuntu loop: - "{{ deploy_path }}/nginx-demo" - "{{ deploy_path }}/nginx-demo/data" @@ -31,6 +33,8 @@ src: "{{ item }}" dest: "{{ deploy_path }}/nginx-demo/{{ item }}" mode: "0644" + owner: ubuntu + group: ubuntu loop: - docker-compose.yaml @@ -39,12 +43,16 @@ src: "../secret/demo/cloudflare.ini" dest: "{{ deploy_path }}/nginx-demo/data/cloudflare.ini" mode: "0600" + owner: ubuntu + group: ubuntu - name: Copy all nginx config files from conf directory ansible.builtin.copy: src: "conf/" dest: "{{ deploy_path }}/nginx-demo/data/nginx/" mode: "0644" + owner: ubuntu + group: ubuntu - name: Check if certificates already exist ansible.builtin.stat: @@ -57,7 +65,7 @@ docker compose run --rm certbot certonly \ --dns-cloudflare \ --dns-cloudflare-credentials /etc/cloudflare.ini \ - --dns-cloudflare-propagation-seconds 30 \ + --dns-cloudflare-propagation-seconds 60 \ -d {{ demo_domains | join(' -d ') }} \ --agree-tos \ --email {{ cert_email }} \ From ba627442008517d1839b10151f537e89fa7cbed3 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 03:07:06 -0600 Subject: [PATCH 16/17] feat: backup and restore SSL certificates to/from S3 - Backup certbot conf to S3 during teardown - Restore certificates from S3 on deploy if backup exists - Survives VM destruction/recreation - Avoids Let's Encrypt rate limits and speeds up deployment Co-Authored-By: Claude Opus 4.5 --- deploy-demo.sh | 22 ++++++++++++++++++++-- teardown-demo.sh | 24 +++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/deploy-demo.sh b/deploy-demo.sh index 79178ff..d0d964d 100755 --- a/deploy-demo.sh +++ b/deploy-demo.sh @@ -124,9 +124,27 @@ run_playbook "wbaoftexas/playbook-demo.yaml" "Step 11: Deploy WBA of Texas" # Note: wbaoftexas restore playbook would go here if needed # run_playbook "wbaoftexas/restore-db-demo.yaml" "Step 12: Restore wbaoftexas database" +# Restore SSL certificates from S3 if backup exists (avoids Let's Encrypt rate limits) +echo -e "${YELLOW}>>> Step 12: Restore SSL certificates from S3 (if backup exists)${NC}" +S3_BUCKET="${CONTABO_S3_BUCKET:-beoftexas-backup}" +run_ssh "mkdir -p /home/ubuntu/deploy/nginx-demo/data/certbot +if AWS_ACCESS_KEY_ID='$CONTABO_S3_ACCESS_KEY' \ + AWS_SECRET_ACCESS_KEY='$CONTABO_S3_SECRET_KEY' \ + aws s3 cp s3://$S3_BUCKET/demo-certbot-backup.tar.gz /tmp/demo-certbot-backup.tar.gz \ + --endpoint-url https://usc1.contabostorage.com 2>/dev/null; then + cd /home/ubuntu/deploy/nginx-demo/data/certbot + tar -xzf /tmp/demo-certbot-backup.tar.gz + rm /tmp/demo-certbot-backup.tar.gz + echo 'Certificates restored from S3 backup' +else + echo 'No certificate backup found in S3, will issue new certificates' +fi" +echo -e "${GREEN}✓ Certificate restore check complete${NC}" +echo "" + # Deploy nginx and cloudflared -run_playbook "nginx-demo/playbook.yaml" "Step 12: Deploy Nginx (reverse proxy + SSL certs)" -run_playbook "cloudflared/playbook.yaml" "Step 13: Deploy Cloudflare Tunnel" +run_playbook "nginx-demo/playbook.yaml" "Step 13: Deploy Nginx (reverse proxy + SSL certs)" +run_playbook "cloudflared/playbook.yaml" "Step 14: Deploy Cloudflare Tunnel" echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}Demo Deployment Complete!${NC}" diff --git a/teardown-demo.sh b/teardown-demo.sh index 26a26b5..b8f2272 100755 --- a/teardown-demo.sh +++ b/teardown-demo.sh @@ -34,6 +34,28 @@ run_ssh() { ssh -o BatchMode=yes ubuntu@$SERVER_IP "$@" } +echo -e "${YELLOW}>>> Backing up SSL certificates to S3...${NC}" +if [ -z "$CONTABO_S3_ACCESS_KEY" ] || [ -z "$CONTABO_S3_SECRET_KEY" ]; then + echo -e "${YELLOW}Warning: S3 credentials not set, skipping certificate backup${NC}" + echo "Set CONTABO_S3_ACCESS_KEY and CONTABO_S3_SECRET_KEY to enable backup" +else + S3_BUCKET="${CONTABO_S3_BUCKET:-beoftexas-backup}" + run_ssh "if [ -d /home/ubuntu/deploy/nginx-demo/data/certbot/conf/live ]; then + cd /home/ubuntu/deploy/nginx-demo/data/certbot + sudo tar -czf /tmp/demo-certbot-backup.tar.gz conf + AWS_ACCESS_KEY_ID='$CONTABO_S3_ACCESS_KEY' \ + AWS_SECRET_ACCESS_KEY='$CONTABO_S3_SECRET_KEY' \ + aws s3 cp /tmp/demo-certbot-backup.tar.gz s3://$S3_BUCKET/demo-certbot-backup.tar.gz \ + --endpoint-url https://usc1.contabostorage.com + rm /tmp/demo-certbot-backup.tar.gz + echo 'Certificates backed up to S3' + else + echo 'No certificates to backup' + fi" +fi +echo -e "${GREEN}✓ Certificate backup complete${NC}" +echo "" + echo -e "${YELLOW}>>> Stopping and removing all demo containers...${NC}" # Stop and remove each service @@ -72,5 +94,5 @@ echo " export CONTABO_S3_SECRET_KEY=your_secret" echo " ./deploy-demo.sh" echo "" echo "Note: Cloudflare Tunnel and DNS records are preserved." -echo " SSL certificates will be re-issued on next deploy." +echo " SSL certificates backed up to S3 and will be restored on next deploy." echo "" From f1fd81c30231eccb80cfc0ad65dae140c1fc2c47 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Wed, 21 Jan 2026 03:29:05 -0600 Subject: [PATCH 17/17] fix: override certbot entrypoint for certonly command The certbot container has an entrypoint that runs 'certbot renew' in a loop, ignoring any command passed. Use --entrypoint '' to clear it so certonly actually runs. Co-Authored-By: Claude Opus 4.5 --- nginx-demo/playbook.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nginx-demo/playbook.yaml b/nginx-demo/playbook.yaml index 5170d91..d3feed1 100644 --- a/nginx-demo/playbook.yaml +++ b/nginx-demo/playbook.yaml @@ -62,7 +62,7 @@ - name: Issue SSL certificates via DNS-01 challenge ansible.builtin.shell: cmd: | - docker compose run --rm certbot certonly \ + docker compose run --rm --entrypoint '' certbot certbot certonly \ --dns-cloudflare \ --dns-cloudflare-credentials /etc/cloudflare.ini \ --dns-cloudflare-propagation-seconds 60 \