From 639b5a3a43c506d261b1bbd36d28a1092b5a8371 Mon Sep 17 00:00:00 2001 From: devkryssie Date: Sun, 27 Sep 2026 21:55:26 +0100 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20EC2=20deployment=20=E2=80=94=20?= =?UTF-8?q?Caddy,=20sslip.io=20TLS,=20deploy=20script,=20DEPLOYMENT.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-instance EC2 deployment that replaces ngrok for stable testing. Files added: docker-compose.prod.yml — production overlay: adds Caddy service, removes direct host ports from checkout/messaging (Caddy handles public traffic) Caddyfile — routes by path: /webhooks/whatsapp → messaging, /webhooks/paystack → settlement-engine, /* → checkout. settlement-engine's internal endpoints (/merchants, /links) are never publicly routed (OQ-022 preserved). deploy.sh — git pull + build + up over SSH, with health checks scripts/ec2_setup.sh — one-shot instance provisioning: Docker install, repo clone, ufw rules (22/80/443 only) DEPLOYMENT.md — step-by-step: EC2 launch, sslip.io hostname derivation, Elastic IP tradeoff, .env setup (incl. permanent WhatsApp token note), stack start, webhook URL updates, redeploy, teardown .env.example — adds PUBLIC_HOSTNAME, RATE_LIMIT_* vars Approach: documented manual setup (not Terraform). Reason: single instance that won't be replicated; Terraform adds complexity without benefit here. Revisit when multi-instance or IaC is needed. --- .env.example | 17 ++- Caddyfile | 34 ++++++ DEPLOYMENT.md | 235 ++++++++++++++++++++++++++++++++++++++++ deploy.sh | 49 +++++++++ docker-compose.prod.yml | 54 +++++++++ scripts/ec2_setup.sh | 64 +++++++++++ 6 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 Caddyfile create mode 100644 DEPLOYMENT.md create mode 100755 deploy.sh create mode 100644 docker-compose.prod.yml create mode 100755 scripts/ec2_setup.sh diff --git a/.env.example b/.env.example index 42ee157..b62bf4e 100644 --- a/.env.example +++ b/.env.example @@ -176,10 +176,23 @@ SENTRY_DSN=PLACEHOLDER_SENTRY_DSN LOG_LEVEL=DEBUG # Reconciliation job lookback window in minutes (OQ-013 / Rule 7). -# The job fetches Paystack transactions from this far back on each run. -# Set slightly longer than the run interval (default 15 min) to catch edge cases. RECONCILIATION_LOOKBACK_MINUTES=20 +# Rate limiting on checkout service (requests per minute per IP). +RATE_LIMIT_PAY_RPM=10 +RATE_LIMIT_CHECKOUT_RPM=60 + +# ----------------------------------------------------------------------------- +# Production / deployment +# ----------------------------------------------------------------------------- + +# sslip.io hostname derived from the EC2 instance's public IP. +# Format: replace dots with dashes, append .sslip.io +# Example: IP 54.123.45.67 → PUBLIC_HOSTNAME=54-123-45-67.sslip.io +# Used by Caddy for TLS certificate issuance and by services for CHECKOUT_BASE_URL. +# See DEPLOYMENT.md for full instructions. +PUBLIC_HOSTNAME=PLACEHOLDER_replace-dots-with-dashes.sslip.io + # ----------------------------------------------------------------------------- # Application diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..130ed9f --- /dev/null +++ b/Caddyfile @@ -0,0 +1,34 @@ +# Caddyfile — ConFam reverse proxy +# +# PUBLIC_HOSTNAME is injected from .env (e.g. 54-123-45-67.sslip.io). +# Caddy automatically obtains and renews a Let's Encrypt TLS certificate. +# +# Public routes: +# /webhooks/whatsapp → messaging:8002 +# /webhooks/paystack → settlement-engine:8000 (webhook path only) +# /{uuid}* → checkout:8001 +# +# settlement-engine's internal endpoints (/merchants, /links, /internal/*) +# are NOT routed here — they stay inside the Docker network only (OQ-022). + +{$PUBLIC_HOSTNAME} { + # Meta WhatsApp webhook (POST inbound + GET verify-token handshake) + handle /webhooks/whatsapp* { + reverse_proxy messaging:8002 + } + + # Paystack webhook (POST only — Caddy forwards all methods) + handle /webhooks/paystack { + reverse_proxy settlement-engine:8000 + } + + # Checkout health check + handle /health { + reverse_proxy checkout:8001 + } + + # Buyer-facing checkout pages: /{uuid}, /{uuid}/json, /{uuid}/pay/bank + handle_path /* { + reverse_proxy checkout:8001 + } +} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..9359b7a --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,235 @@ +# ConFam — Deployment Guide + +This document covers deploying ConFam to a single EC2 instance for pilot testing (~5 merchants). This is not a production-grade multi-instance setup — that comes later when there's a real reason to need it. + +--- + +## Architecture on the instance + +``` +Internet + │ + 443/80 (HTTPS/HTTP) + │ + Caddy (reverse proxy, auto-TLS via Let's Encrypt) + │ + ├── /webhooks/whatsapp* ──→ messaging:8002 (Meta webhook) + ├── /webhooks/paystack ──→ settlement-engine:8000 (Paystack webhook only) + └── /{everything else} ──→ checkout:8001 (buyer checkout pages) + +Internal Docker network only (not reachable from internet): + settlement-engine:8000 — /merchants, /links, /internal/* + postgres:5432 +``` + +OQ-022 network isolation is preserved: settlement-engine's internal endpoints are never reachable from the public internet. + +--- + +## Step 1: Launch an EC2 instance + +1. In the AWS Console (or CLI), launch an EC2 instance: + - **AMI**: Ubuntu 24.04 LTS + - **Instance type**: `t3.small` (2 vCPU, 2GB RAM — adequate for ~5 testers) + - **Storage**: 20GB gp3 + - **Security group** — inbound rules: + | Port | Source | Purpose | + |------|--------|---------| + | 22 | Your IP only (`x.x.x.x/32`) | SSH | + | 80 | 0.0.0.0/0 | Let's Encrypt HTTP challenge | + | 443 | 0.0.0.0/0 | HTTPS (Caddy) | + - No other ports open publicly. + +2. **Elastic IP (recommended):** Allocate an Elastic IP and associate it with the instance. Without this, the instance gets a new public IP on every restart, which means: + - The sslip.io hostname changes + - You must update Meta and Paystack webhook URLs again + - An Elastic IP costs ~$0/month while attached to a running instance + + If you skip the Elastic IP, accept that a restart requires a one-time webhook URL update in both dashboards (documented in Step 6 below). + +3. Note the public IP address. + +--- + +## Step 2: Derive the sslip.io hostname + +Replace dots in the IP with dashes and append `.sslip.io`: + +``` +IP: 54.123.45.67 +Hostname: 54-123-45-67.sslip.io +``` + +This hostname resolves to your IP automatically — no signup, no DNS config. Let's Encrypt will issue a real TLS certificate for it. + +Test it resolves correctly: +```bash +dig 54-123-45-67.sslip.io +short # should return 54.123.45.67 +``` + +--- + +## Step 3: Provision the instance + +SSH in and run the setup script: + +```bash +ssh -i your-key.pem ubuntu@ + +# Option A: run directly from GitHub +curl -fsSL https://raw.githubusercontent.com/DevSolex/ConFam/main/scripts/ec2_setup.sh | bash + +# Option B: if repo is already cloned +bash /home/ubuntu/ConFam/scripts/ec2_setup.sh +``` + +This installs Docker, clones the repo, copies `.env.example` → `.env`, and opens ports 22/80/443 in ufw. + +After the script completes, log out and back in (or run `newgrp docker`) so the docker group takes effect. + +--- + +## Step 4: Configure `.env` on the instance + +```bash +nano /home/ubuntu/ConFam/.env +``` + +Fill in every `PLACEHOLDER_` value. Key ones: + +```bash +# Instance hostname (from Step 2) +PUBLIC_HOSTNAME=54-123-45-67.sslip.io + +# Database passwords — choose your own strong passwords +POSTGRES_SUPERUSER_PASSWORD=choose_a_strong_password +CONFAM_APP_PASSWORD=choose_a_strong_password +CONFAM_MIGRATOR_PASSWORD=choose_a_strong_password + +# Paystack — use test keys until you're ready for live +PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxx +PAYSTACK_WEBHOOK_SECRET=sk_test_xxxxxxxxxxxx + +# Meta WhatsApp Cloud API +WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id +WHATSAPP_ACCESS_TOKEN=your_permanent_system_user_token +WHATSAPP_APP_SECRET=your_app_secret +WHATSAPP_VERIFY_TOKEN=choose_any_string + +# Checkout URL — must match the public hostname +CHECKOUT_BASE_URL=https://54-123-45-67.sslip.io + +# Sentry (optional but recommended for production) +SENTRY_DSN=your_sentry_dsn + +# Cooling-off for payout account changes (48h default) +PAYOUT_ACCOUNT_COOLING_OFF_SECONDS=172800 +``` + +**Important — permanent WhatsApp access token:** The temporary token from the Meta dashboard expires in 24 hours. For a stable deployment, create a System User token: +1. Meta Business Suite → Settings → System Users +2. Create a system user, add your WhatsApp app, generate a permanent token +3. Use that token as `WHATSAPP_ACCESS_TOKEN` + +Lock down the file: +```bash +chmod 600 /home/ubuntu/ConFam/.env +``` + +--- + +## Step 5: Start the stack + +```bash +cd /home/ubuntu/ConFam +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +Watch the logs to confirm migrations ran and all services started: +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f +``` + +Verify Caddy obtained a TLS certificate (check logs for `certificate obtained successfully`): +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml logs caddy +``` + +Health check: +```bash +curl https://54-123-45-67.sslip.io/health +# {"status":"ok","service":"checkout"} +``` + +--- + +## Step 6: Update webhook URLs (one-time manual step) + +### Paystack +1. Paystack Dashboard → Settings → API Keys & Webhooks +2. Set webhook URL to: + ``` + https://54-123-45-67.sslip.io/webhooks/paystack + ``` + +### Meta WhatsApp +1. Meta App Dashboard → WhatsApp → Configuration → Webhook +2. Set callback URL to: + ``` + https://54-123-45-67.sslip.io/webhooks/whatsapp + ``` +3. Set verify token to whatever you put in `WHATSAPP_VERIFY_TOKEN` +4. Click "Verify and Save" — Caddy must be running for this to succeed + +--- + +## Step 7: Verify the full flow + +1. Send `REGISTER My Business` to your WhatsApp number → should get a reply +2. Send `ONBOARD 0123456789 058` → Paystack verifies bank, subaccount created +3. Send `PAY 750 Test item` → get a checkout URL back +4. Open the URL in a browser → checkout page renders at the sslip.io domain +5. Complete a test payment → receive "Payment received ✅" WhatsApp message + +--- + +## Redeploying (code updates) + +From your local machine: +```bash +./deploy.sh ubuntu@54.123.45.67 +``` + +Or directly on the instance: +```bash +cd /home/ubuntu/ConFam +git pull origin main +docker compose -f docker-compose.yml -f docker-compose.prod.yml build +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +--- + +## If the instance is restarted (no Elastic IP) + +The public IP changes → the sslip.io hostname changes → you must: + +1. Update `PUBLIC_HOSTNAME` in `/home/ubuntu/ConFam/.env` +2. Update `CHECKOUT_BASE_URL` in `.env` +3. Restart Caddy: `docker compose -f docker-compose.yml -f docker-compose.prod.yml restart caddy` +4. Update Paystack webhook URL (Step 6 above) +5. Update Meta webhook URL (Step 6 above) + +**Recommendation:** Allocate an Elastic IP to avoid this entirely. + +--- + +## Teardown + +```bash +# Stop the stack (keeps data) +docker compose -f docker-compose.yml -f docker-compose.prod.yml down + +# Full reset including database volume +docker compose -f docker-compose.yml -f docker-compose.prod.yml down -v +``` diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..dc01749 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# ============================================================================= +# deploy.sh — Deploy or redeploy ConFam on the EC2 instance +# +# Usage from local machine: +# ./deploy.sh ubuntu@ +# +# Usage directly on the instance: +# ./deploy.sh +# +# Requires: SSH key auth set up, .env present on the instance. +# ============================================================================= + +set -euo pipefail + +REMOTE="${1:-}" +APP_DIR="/home/ubuntu/ConFam" +COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml" + +run() { + if [ -n "$REMOTE" ]; then + ssh "$REMOTE" "cd $APP_DIR && $*" + else + eval "$*" + fi +} + +echo "[deploy] Pulling latest code from main..." +run "git pull origin main" + +echo "[deploy] Rebuilding images..." +run "$COMPOSE build" + +echo "[deploy] Restarting stack..." +run "$COMPOSE up -d" + +echo "[deploy] Waiting for services to start..." +sleep 8 + +echo "[deploy] Health checks..." +if [ -n "$REMOTE" ]; then + HOSTNAME=$(ssh "$REMOTE" "grep PUBLIC_HOSTNAME $APP_DIR/.env | cut -d= -f2 | tr -d '\r'") + curl -sf "https://$HOSTNAME/health" && echo " ✓ checkout healthy" || echo " ✗ checkout failed" +else + curl -sf http://localhost:8001/health && echo " ✓ checkout" || echo " ✗ checkout" + curl -sf http://localhost:8002/health && echo " ✓ messaging" || echo " ✗ messaging" +fi + +echo "[deploy] Done." diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..c990cb1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,54 @@ +# docker-compose.prod.yml +# +# Production overlay — adds Caddy reverse proxy for HTTPS termination. +# Removes direct host port exposure from checkout/messaging (Caddy handles it). +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# Caddy handles: +# - Automatic TLS via Let's Encrypt (requires ports 80+443 open on the instance) +# - Routing public traffic to the correct internal service +# - Keeping settlement-engine's internal endpoints off the public internet +# +# The sslip.io hostname is set via PUBLIC_HOSTNAME in .env: +# PUBLIC_HOSTNAME=54-123-45-67.sslip.io +# +# See DEPLOYMENT.md for provisioning steps. + +services: + # Override checkout: remove direct host port — Caddy handles public traffic + checkout: + ports: [] + + # Override messaging: remove direct host port — Caddy handles public traffic + messaging: + ports: [] + + # Override postgres: remove host port — not needed in production + postgres: + ports: [] + + # Caddy reverse proxy — public HTTPS termination + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + environment: + PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME} + depends_on: + - checkout + - messaging + - settlement-engine + networks: + - default + +volumes: + caddy_data: + caddy_config: diff --git a/scripts/ec2_setup.sh b/scripts/ec2_setup.sh new file mode 100755 index 0000000..de39a00 --- /dev/null +++ b/scripts/ec2_setup.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# ============================================================================= +# scripts/ec2_setup.sh +# +# Run once on a fresh Ubuntu 24.04 EC2 instance to install Docker, +# clone the repo, and prepare the environment. +# +# Usage (as ubuntu user after SSH): +# curl -fsSL https://raw.githubusercontent.com/DevSolex/ConFam/main/scripts/ec2_setup.sh | bash +# OR copy the file and run: +# bash ec2_setup.sh +# ============================================================================= + +set -euo pipefail + +echo "=== [1/5] System update ===" +sudo apt-get update -q +sudo apt-get upgrade -yq + +echo "=== [2/5] Install Docker ===" +sudo apt-get install -yq ca-certificates curl gnupg +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update -q +sudo apt-get install -yq docker-ce docker-ce-cli containerd.io docker-compose-plugin +# Allow ubuntu user to run docker without sudo +sudo usermod -aG docker ubuntu +echo "Docker installed: $(docker --version)" + +echo "=== [3/5] Clone repository ===" +cd /home/ubuntu +git clone https://github.com/DevSolex/ConFam.git +cd ConFam +echo "Repository cloned." + +echo "=== [4/5] Create .env ===" +# Copy the example — you MUST fill in real values before starting the stack +cp .env.example .env +chmod 600 .env +echo "" +echo "IMPORTANT: Edit /home/ubuntu/ConFam/.env with real values before running the stack." +echo " - Set PUBLIC_HOSTNAME to your sslip.io hostname (see DEPLOYMENT.md)" +echo " - Set all PAYSTACK_*, WHATSAPP_*, and database passwords" +echo "" + +echo "=== [5/5] Open firewall ports (ufw) ===" +# Allow SSH, HTTP (for Let's Encrypt), HTTPS +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw --force enable +sudo ufw status + +echo "" +echo "=== Setup complete ===" +echo "Next steps:" +echo " 1. newgrp docker (or log out and back in for docker group to take effect)" +echo " 2. nano /home/ubuntu/ConFam/.env (fill in real values)" +echo " 3. docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d" +echo " 4. See DEPLOYMENT.md for the full checklist."