Skip to content

feat: add demo environment with Cloudflare Tunnel#7

Open
casmith wants to merge 17 commits intomasterfrom
feat/demo-environment
Open

feat: add demo environment with Cloudflare Tunnel#7
casmith wants to merge 17 commits intomasterfrom
feat/demo-environment

Conversation

@casmith
Copy link
Owner

@casmith casmith commented Jan 21, 2026

Summary

  • Add demo/staging environment infrastructure for local VM deployment
  • Cloudflare Tunnel for public internet access (no firewall/port forwarding needed)
  • Let's Encrypt SSL via DNS-01 challenge using Cloudflare DNS API
  • Services: beoftexas, strapi, wbaoftexas (no projectsend)

Playbook Sharing Analysis

Shared vs Demo-Specific Files

Component Shared with Prod Demo-Specific Notes
beoftexas docker/nginx/default.conf (base) playbook-demo.yaml, docker-compose-demo.yaml, docker/nginx-demo/default.conf Demo needs different STRAPI_URL and CSP header
strapi docker-compose.yaml playbook-demo.yaml Same docker-compose, different playbook for host/secrets path
wbaoftexas docker-compose.yaml playbook-demo.yaml Same docker-compose, different playbook for host/secrets path
nginx None All new (nginx-demo/) Demo uses DNS-01 challenge vs HTTP-01, different certs
cloudflared N/A All new Demo-only infrastructure

Key Differences in Demo Playbooks

  1. Host target: hosts: demo instead of hosts: web01
  2. Secrets path: ../secret/demo/ instead of ../secret/
  3. Nginx restart path: nginx-demo instead of nginx
  4. beoftexas: Uses docker-compose-demo.yaml (sets STRAPI_URL: https://demo-strapi.beoftexas.com)

Potential Refactoring Opportunities

The demo playbooks could potentially be consolidated with prod playbooks using:

  • Ansible variables for environment-specific values
  • Conditional includes based on inventory group
  • Shared roles with environment-specific vars

However, separate playbooks provide:

  • Clearer separation of environments
  • Easier to understand deployment flow
  • Less risk of accidentally deploying to wrong environment

Files Added (20 files, 703 lines)

hosts-demo.yaml                              # Demo inventory
deploy-demo.sh                               # One-command deployment

cloudflared/
├── docker-compose.yaml                      # Tunnel container
└── playbook.yaml                            # Tunnel deployment

nginx-demo/
├── docker-compose.yaml                      # Nginx + certbot/dns-cloudflare
├── playbook.yaml                            # Nginx + cert issuance
└── conf/
    ├── demo.beoftexas.com.conf
    ├── demo-strapi.beoftexas.com.conf
    └── demo.wbaoftexas.com.conf

beoftexas/
├── docker-compose-demo.yaml                 # STRAPI_URL override
├── playbook-demo.yaml
└── docker/nginx-demo/default.conf           # Updated CSP header

strapi/playbook-demo.yaml
wbaoftexas/playbook-demo.yaml

secret/demo/                                 # Placeholder credentials (6 files)

Test plan

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@casmith
Copy link
Owner Author

casmith commented Jan 21, 2026

Refactoring Proposal: Reusable Ansible Roles

After analyzing the playbooks, here's a design for reducing duplication using Ansible roles.

Proposed Directory Structure

roles/
├── docker-service/
│   ├── defaults/main.yaml
│   └── tasks/main.yaml
└── common/
    └── tasks/
        ├── docker-login.yaml
        └── create-network.yaml

Role: docker-service

roles/docker-service/defaults/main.yaml

service_name: ""
env: prod
dockerhub_login: true
create_network: true
restart_nginx: true

# Derived paths
base_path: "{{ deploy_path }}/{{ service_name }}"
secrets_src: "../secret/{{ 'demo/' if env == 'demo' else '' }}"
compose_file: "docker-compose{{ '-demo' if env == 'demo' else '' }}.yaml"
nginx_path: "{{ deploy_path }}/nginx{{ '-demo' if env == 'demo' else '' }}"

roles/docker-service/tasks/main.yaml

- name: Log into DockerHub
  docker_login:
    username: '{{ lookup("env", "DOCKERHUB_USERNAME") }}'
    password: '{{ lookup("env", "DOCKERHUB_PASSWORD") }}'
  when: dockerhub_login

- name: Create directories
  ansible.builtin.file:
    path: "{{ item }}"
    state: directory
  loop: "{{ directories }}"

- name: Create shared Docker network
  community.docker.docker_network:
    name: shared
    state: present
  when: create_network

- name: Copy docker-compose file
  ansible.builtin.copy:
    src: "{{ compose_file }}"
    dest: "{{ base_path }}/docker-compose.yaml"

- name: Copy secrets
  ansible.builtin.copy:
    src: "{{ secrets_src }}{{ item }}"
    dest: "{{ base_path }}/secrets/{{ item }}"
    mode: '0600'
  loop: "{{ secret_files | default([]) }}"
  when: secret_files is defined

- name: Copy env file
  ansible.builtin.copy:
    src: "{{ secrets_src }}{{ env_file }}"
    dest: "{{ base_path }}/.env"
    mode: '0600'
  when: env_file is defined

- name: Deploy with docker compose
  ansible.builtin.shell:
    cmd: "{{ compose_command | default('docker compose up -d') }}"
    chdir: "{{ base_path }}"

- name: Restart nginx proxy
  ansible.builtin.shell:
    cmd: "docker compose restart web"
    chdir: "{{ nginx_path }}"
  failed_when: false
  when: restart_nginx

Refactored Playbooks

beoftexas/playbook.yaml (handles both prod and demo)

- name: Deploy Benefit Elect of Texas
  hosts: "{{ 'demo' if env == 'demo' else 'web01' }}"
  become: true
  vars:
    service_name: beoftexas
    env: "{{ env | default('prod') }}"
    env_file: beoftexas.env
    directories:
      - "{{ deploy_path }}/beoftexas"
      - "{{ deploy_path }}/beoftexas/data"
      - "{{ deploy_path }}/beoftexas/docker/nginx"
  roles:
    - docker-service
  tasks:
    - name: Copy nginx config
      ansible.builtin.copy:
        src: "docker/nginx{{ '-demo' if env == 'demo' else '' }}/default.conf"
        dest: "{{ deploy_path }}/beoftexas/docker/nginx/default.conf"

    - name: Reload beoftexas nginx
      ansible.builtin.shell:
        cmd: "docker compose exec -T nginx nginx -s reload"
        chdir: "{{ deploy_path }}/beoftexas/"
      failed_when: false

    - name: Wait for services and run migrations
      # ... beoftexas-specific tasks

strapi/playbook.yaml (handles both prod and demo)

- name: Deploy Strapi
  hosts: "{{ 'demo' if env == 'demo' else 'web01' }}"
  become: true
  vars:
    service_name: strapi
    env: "{{ env | default('prod') }}"
    secret_files:
      - strapi_db_pw.txt
    directories:
      - "{{ deploy_path }}/strapi"
      - "{{ deploy_path }}/strapi/data"
      - "{{ deploy_path }}/strapi/secrets"
  roles:
    - docker-service

Usage

# Production deployment (default)
ansible-playbook -i hosts.yaml beoftexas/playbook.yaml

# Demo deployment
ansible-playbook -i hosts-demo.yaml beoftexas/playbook.yaml -e env=demo

Impact Summary

Metric Current After Refactor
Playbook files 10 5
Lines of YAML ~400 ~150
Duplication High Minimal

Trade-offs

Pros:

  • Single source of truth per service
  • Environment is a parameter, not a separate file
  • Easier to add new environments (staging, QA, etc.)
  • Common bugs fixed once

Cons:

  • More complex mental model (need to understand role + vars)
  • Harder to see full deployment at a glance
  • Requires Ansible role knowledge

This refactoring could be done in a follow-up PR if desired.

casmith and others added 16 commits January 20, 2026 23:44
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant