From b1b424872041e54a5ea6e08ad621dcb00b5d880b Mon Sep 17 00:00:00 2001 From: dhia Date: Mon, 11 May 2026 16:39:06 +0100 Subject: [PATCH] Add VPS deploy kit for e-sign.360dmmc.com Runbook, docker-compose overlay, bootstrap/backup/restore scripts, deliverability helpers, proxy snippets, and a sample template fixture for first-run smoke testing. Ignore local pg_data/ volume. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + deploy/.env.example | 35 +++++ deploy/.gitignore | 4 + deploy/RUNBOOK.md | 96 +++++++++++++ deploy/backup.sh | 22 +++ deploy/bootstrap.sh | 102 ++++++++++++++ deploy/deliverability/local_validate.sh | 57 ++++++++ deploy/deliverability/recipients.example.csv | 7 + deploy/deliverability/send_batch.py | 129 ++++++++++++++++++ deploy/deliverability/upload_template.py | 60 ++++++++ deploy/docker-compose.behind-proxy.yml | 34 +++++ deploy/docker-compose.prod.yml | 34 +++++ deploy/health_check.sh | 65 +++++++++ deploy/proxy-snippets/Caddyfile.snippet | 11 ++ deploy/proxy-snippets/nginx.conf | 34 +++++ deploy/proxy-snippets/traefik-labels.yml | 17 +++ deploy/restore.sh | 26 ++++ deploy/test-fixtures/gen_service_agreement.py | 46 +++++++ deploy/test-fixtures/service_agreement.pdf | 74 ++++++++++ 19 files changed, 855 insertions(+) create mode 100644 deploy/.env.example create mode 100644 deploy/.gitignore create mode 100644 deploy/RUNBOOK.md create mode 100644 deploy/backup.sh create mode 100644 deploy/bootstrap.sh create mode 100644 deploy/deliverability/local_validate.sh create mode 100644 deploy/deliverability/recipients.example.csv create mode 100644 deploy/deliverability/send_batch.py create mode 100644 deploy/deliverability/upload_template.py create mode 100644 deploy/docker-compose.behind-proxy.yml create mode 100644 deploy/docker-compose.prod.yml create mode 100644 deploy/health_check.sh create mode 100644 deploy/proxy-snippets/Caddyfile.snippet create mode 100644 deploy/proxy-snippets/nginx.conf create mode 100644 deploy/proxy-snippets/traefik-labels.yml create mode 100644 deploy/restore.sh create mode 100644 deploy/test-fixtures/gen_service_agreement.py create mode 100644 deploy/test-fixtures/service_agreement.pdf diff --git a/.gitignore b/.gitignore index d14f4595..19d44cb5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ yarn-debug.log* /ee dump.rdb *.onnx + +/pg_data diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 00000000..d42230a1 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,35 @@ +# Copy to deploy/.env on the VPS and fill in. Never commit deploy/.env. +# Used by: docker compose --env-file deploy/.env -f docker-compose.yml -f deploy/docker-compose.prod.yml up -d + +# --- Required --- +HOST=e-sign.360dmmc.com +SECRET_KEY_BASE= # generate: openssl rand -hex 64 + +# --- Database (postgres service in compose) --- +POSTGRES_USER=docuseal +POSTGRES_PASSWORD= # generate: openssl rand -hex 24 +POSTGRES_DB=docuseal +DATABASE_URL=postgres://docuseal:REPLACE_ME@postgres:5432/docuseal + +# --- SMTP (Microsoft Exchange / Office 365) --- +# DocuSeal also lets you set SMTP per-account via the admin UI; these env vars +# act as a baseline. Production "from" address and display name are configured +# in the admin UI after first login. +SMTP_ADDRESS=smtp.office365.com +SMTP_PORT=587 +SMTP_DOMAIN=360dmmc.com +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_AUTHENTICATION=login +SMTP_ENABLE_STARTTLS=true + +# --- Optional: S3 for attachments (recommended once HIPAA-relevant data lands) --- +# Leave blank for MVP; DocuSeal will store attachments on the local volume. +# S3_ATTACHMENTS_BUCKET= +# AWS_REGION=us-east-1 +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= + +# --- Optional: Sidekiq web UI auth (admin-only background job dashboard) --- +# SIDEKIQ_BASIC_AUTH_USERNAME=admin +# SIDEKIQ_BASIC_AUTH_PASSWORD= diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 00000000..d2adb565 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,4 @@ +.env +backups/ +__pycache__/ +*.pyc diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md new file mode 100644 index 00000000..a2bae666 --- /dev/null +++ b/deploy/RUNBOOK.md @@ -0,0 +1,96 @@ +# DocuSeal VPS Deploy Runbook (KVM1 → e-sign.360dmmc.com) + +## Prerequisites (from Lohith) +- SSH access to KVM1 (sudo-capable user) +- DNS A-record: `e-sign.360dmmc.com` → KVM1 public IPv4 +- Firewall: 80/tcp, 443/tcp inbound (world); 22/tcp inbound (admin); 587/tcp outbound to `smtp.office365.com` +- Ubuntu 22.04, ≥2 GB RAM, ≥20 GB disk + +## 1. Install Docker (if absent) +```sh +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER +# log out/in for group change to take effect +``` + +## 2. Clone repo +```sh +sudo mkdir -p /opt/docuseal && sudo chown $USER:$USER /opt/docuseal +git clone https://github.com/Dhia-mastouri/360-e-sign.git /opt/docuseal +cd /opt/docuseal +``` + +## 3. Configure environment +```sh +cp deploy/.env.example deploy/.env +# Generate secrets: +echo "SECRET_KEY_BASE=$(openssl rand -hex 64)" >> deploy/.env # then dedupe +echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> deploy/.env +# Edit deploy/.env: set HOST, SMTP_USERNAME, SMTP_PASSWORD, DATABASE_URL (use the same POSTGRES_PASSWORD) +chmod 600 deploy/.env +``` + +## 4. Verify DNS before bringing Caddy up +```sh +dig +short e-sign.360dmmc.com +# Must return KVM1 public IP. If not, wait or fix with Lohith before next step. +``` + +## 5. Bring stack up +```sh +docker compose --env-file deploy/.env \ + -f docker-compose.yml -f deploy/docker-compose.prod.yml \ + up -d +docker compose logs -f app +# Wait for "Listening on http://0.0.0.0:3000" +``` + +Caddy will obtain a Let's Encrypt cert automatically on first request (~30 s). + +## 6. First-run admin setup +- Open `https://e-sign.360dmmc.com` +- Complete admin onboarding (email, password, company) +- Settings → Email → SMTP: confirm Exchange creds, send a test mail to yourself + +## 7. Schedule backups +```sh +chmod +x deploy/backup.sh deploy/restore.sh +sudo crontab -e +# Add: 0 2 * * * /opt/docuseal/deploy/backup.sh >> /var/log/docuseal-backup.log 2>&1 +``` + +## 8. Smoke test +- Upload AI-generated service-agreement PDF as a template +- Drop Signature + Date + Name fields +- Send to a real recipient +- Verify completed PDF + audit log download + +## Upstream sync +```sh +git fetch upstream +git checkout dev +git merge upstream/master # or upstream/main +# Resolve conflicts in deploy/ should never happen; they will only ever appear +# in upstream-tracked files. If they do, resolve in favor of upstream and +# re-apply our 360DMMC overlay separately. +docker compose pull && docker compose up -d +``` + +## Rollback +```sh +docker compose down +./deploy/restore.sh /opt/docuseal/backups/ +docker compose --env-file deploy/.env -f docker-compose.yml -f deploy/docker-compose.prod.yml up -d +``` + +## Health checks +- `curl -I https://e-sign.360dmmc.com` → expect `200` or `302` +- `docker compose ps` → all services `Up` +- `docker compose logs --tail 50 app` → no `ERROR` lines + +## HIPAA pre-flight (before real PHI) +- [ ] Microsoft 365 BAA signed (covers Exchange SMTP) +- [ ] Postgres volume on encrypted disk (`cryptsetup` or cloud-provider encrypted disk) +- [ ] Off-site backup target (encrypted) configured in `deploy/backup.sh` +- [ ] Audit log retention policy documented +- [ ] Access list reviewed (who has KVM1 sudo, who has DocuSeal admin) diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100644 index 00000000..1261444c --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Daily backup of Postgres + attachments. Run from /opt/docuseal repo root. +# Suggested cron: 0 2 * * * /opt/docuseal/deploy/backup.sh >> /var/log/docuseal-backup.log 2>&1 + +set -euo pipefail + +BACKUP_ROOT="${BACKUP_ROOT:-/opt/docuseal/backups}" +RETAIN_DAYS="${RETAIN_DAYS:-14}" +STAMP=$(date -u +%Y%m%d-%H%M%S) +DEST="$BACKUP_ROOT/$STAMP" + +mkdir -p "$DEST" + +docker compose exec -T postgres \ + pg_dump -U "${POSTGRES_USER:-docuseal}" -d "${POSTGRES_DB:-docuseal}" -Fc \ + > "$DEST/postgres.dump" + +tar czf "$DEST/attachments.tgz" -C /opt/docuseal data 2>/dev/null || true + +find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETAIN_DAYS" -exec rm -rf {} + + +echo "[$(date -u +%FT%TZ)] backup ok -> $DEST ($(du -sh "$DEST" | cut -f1))" diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh new file mode 100644 index 00000000..a98239c1 --- /dev/null +++ b/deploy/bootstrap.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# One-shot DocuSeal bootstrap for KVM1. +# Run as a sudo-capable user. Idempotent: safe to re-run. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/Dhia-mastouri/360-e-sign/dev/deploy/bootstrap.sh | bash -s -- [proxy|caddy] +# +# Modes: +# caddy - DocuSeal manages SSL via bundled Caddy. Requires ports 80+443 free. +# proxy - DocuSeal binds to 127.0.0.1:3000, an existing reverse proxy fronts it. +# +# After running, edit /opt/docuseal/deploy/.env and fill SMTP_USERNAME, SMTP_PASSWORD, +# then run: cd /opt/docuseal && docker compose --env-file deploy/.env -f docker-compose.yml -f deploy/docker-compose..yml up -d + +set -euo pipefail + +MODE="${1:-caddy}" +case "$MODE" in + caddy) OVERLAY="docker-compose.prod.yml" ;; + proxy) OVERLAY="docker-compose.behind-proxy.yml" ;; + *) echo "usage: $0 [caddy|proxy]"; exit 1 ;; +esac + +REPO_URL="https://github.com/Dhia-mastouri/360-e-sign.git" +INSTALL_DIR="/opt/docuseal" +BRANCH="dev" +HOST_DEFAULT="e-sign.360dmmc.com" + +echo "==> mode: $MODE (overlay: $OVERLAY)" + +if ! command -v docker >/dev/null 2>&1; then + echo "==> installing Docker" + curl -fsSL https://get.docker.com | sudo sh + sudo usermod -aG docker "$USER" + echo " Docker installed. Log out and back in for group membership, then re-run." + exit 0 +fi + +if [ ! -d "$INSTALL_DIR" ]; then + echo "==> cloning repo to $INSTALL_DIR" + sudo mkdir -p "$INSTALL_DIR" + sudo chown "$USER:$USER" "$INSTALL_DIR" + git clone -b "$BRANCH" "$REPO_URL" "$INSTALL_DIR" +else + echo "==> repo exists, pulling latest" + git -C "$INSTALL_DIR" fetch origin "$BRANCH" + git -C "$INSTALL_DIR" checkout "$BRANCH" + git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH" +fi + +cd "$INSTALL_DIR" + +mkdir -p /opt/docuseal/data /opt/docuseal/pg_data /opt/docuseal/caddy /opt/docuseal/backups + +if [ ! -f deploy/.env ]; then + echo "==> generating deploy/.env from template" + cp deploy/.env.example deploy/.env + PG_PASS=$(openssl rand -hex 24) + SECRET=$(openssl rand -hex 64) + sed -i "s|^SECRET_KEY_BASE=.*|SECRET_KEY_BASE=$SECRET|" deploy/.env + sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$PG_PASS|" deploy/.env + sed -i "s|REPLACE_ME|$PG_PASS|" deploy/.env + sed -i "s|^HOST=.*|HOST=$HOST_DEFAULT|" deploy/.env + chmod 600 deploy/.env + echo " .env generated. EDIT IT NOW to set SMTP_USERNAME / SMTP_PASSWORD before continuing." + echo " Edit: nano $INSTALL_DIR/deploy/.env" + exit 0 +fi + +if grep -q "^SMTP_USERNAME=$" deploy/.env || grep -q "^SMTP_PASSWORD=$" deploy/.env; then + echo "!! SMTP_USERNAME and/or SMTP_PASSWORD are still empty in deploy/.env." + echo " Edit deploy/.env and re-run." + exit 1 +fi + +echo "==> bringing stack up" +docker compose --env-file deploy/.env -f docker-compose.yml -f "deploy/$OVERLAY" pull +docker compose --env-file deploy/.env -f docker-compose.yml -f "deploy/$OVERLAY" up -d + +echo "==> waiting for app to be ready" +for i in $(seq 1 60); do + if docker compose logs app 2>/dev/null | grep -q "Listening on http://0.0.0.0:3000"; then + echo " app ready" + break + fi + sleep 2 +done + +echo "==> done" +docker compose ps + +cat < Email -> SMTP: confirm Exchange creds, send test mail + 4. Generate an API token (Settings -> API) + 5. Run health check: deploy/health_check.sh + 6. Schedule backups: + sudo crontab -l 2>/dev/null | { cat; echo '0 2 * * * $INSTALL_DIR/deploy/backup.sh >> /var/log/docuseal-backup.log 2>&1'; } | sudo crontab - +EOF diff --git a/deploy/deliverability/local_validate.sh b/deploy/deliverability/local_validate.sh new file mode 100644 index 00000000..a9c1ef07 --- /dev/null +++ b/deploy/deliverability/local_validate.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Validate upload_template.py + send_batch.py against the LOCAL docker stack. +# Catches script bugs before we point them at prod. +# +# Prerequisites: +# 1. Local stack running: docker compose ps shows app + postgres Up +# 2. Admin user created (you said you did this earlier) +# 3. API token generated in the local UI: +# Settings -> API -> copy the token +# 4. Export it: +# export DOCUSEAL_API_KEY= +# 5. (Optional) export TEST_RECIPIENT= to receive the test invite. +# Without SMTP configured locally, the email won't send -- but the API call +# still creates the submission, which exercises send_batch.py end-to-end. +# +# Usage: deploy/deliverability/local_validate.sh + +set -euo pipefail + +: "${DOCUSEAL_API_KEY:?Set DOCUSEAL_API_KEY (Settings -> API in the local UI)}" + +export DOCUSEAL_URL="http://localhost:3000" +export DOCUSEAL_API_KEY +TEST_RECIPIENT="${TEST_RECIPIENT:-validate@example.invalid}" + +echo "==> 1. Health probe" +code=$(curl -s -o /dev/null -w "%{http_code}" "$DOCUSEAL_URL/api/templates?limit=1" \ + -H "X-Auth-Token: $DOCUSEAL_API_KEY") +[ "$code" = "200" ] || { echo "API auth failed (HTTP $code) -- check token"; exit 1; } +echo " API auth OK" + +echo "==> 2. Upload test template" +TPL_OUT=$(python deploy/deliverability/upload_template.py) +echo "$TPL_OUT" +TEMPLATE_ID=$(echo "$TPL_OUT" | grep -oP 'template_id=\K\d+') +[ -n "$TEMPLATE_ID" ] || { echo "could not parse template_id"; exit 1; } +export TEMPLATE_ID +echo " template_id=$TEMPLATE_ID" + +echo "==> 3. Tiny send batch (1 recipient)" +TMP_CSV=$(mktemp --suffix=.csv) +trap 'rm -f "$TMP_CSV"' EXIT +cat > "$TMP_CSV" < int: + r = S.post( + f"{URL}/api/submissions", + json={ + "template_id": TEMPLATE_ID, + "send_email": True, + "submitters": [{"email": email, "name": name, "role": "Client"}], + }, + timeout=30, + ) + r.raise_for_status() + payload = r.json() + sub = payload[0] if isinstance(payload, list) else payload + return sub["submission_id"] if "submission_id" in sub else sub["id"] + + +def fetch_submission(sid: int) -> dict: + r = S.get(f"{URL}/api/submissions/{sid}", timeout=30) + r.raise_for_status() + return r.json() + + +def main(csv_path: str) -> int: + rows: list[dict] = [] + with open(csv_path, newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(row) + + print(f"sending {len(rows)} submissions to {URL} (template {TEMPLATE_ID})") + sent: list[tuple[dict, int]] = [] + send_errors: list[tuple[dict, str]] = [] + for row in rows: + try: + sid = create_submission(row["email"], row["name"]) + sent.append((row, sid)) + print(f" ✓ {row['email']:40s} -> submission {sid}") + except requests.HTTPError as e: + send_errors.append((row, f"{e.response.status_code} {e.response.text[:120]}")) + print(f" ✗ {row['email']:40s} -> {e}") + time.sleep(0.5) + + print(f"\npolling for {POLL_SECS}s to capture sent/opened events...") + deadline = time.time() + POLL_SECS + final: dict[int, dict] = {} + while time.time() < deadline and len(final) < len(sent): + for row, sid in sent: + if sid in final: + continue + try: + sub = fetch_submission(sid) + submitter = sub["submitters"][0] if sub.get("submitters") else {} + if submitter.get("sent_at") or submitter.get("opened_at"): + final[sid] = {"row": row, "submitter": submitter} + except requests.HTTPError: + pass + time.sleep(10) + + by_provider: dict[str, Counter] = defaultdict(Counter) + for row, sid in sent: + provider = row.get("provider", "unknown") + by_provider[provider]["total"] += 1 + st = final.get(sid, {}).get("submitter", {}) + if st.get("sent_at"): + by_provider[provider]["sent"] += 1 + if st.get("opened_at"): + by_provider[provider]["opened"] += 1 + + print("\n=== Results ===") + print(f"API submission errors: {len(send_errors)}") + for row, err in send_errors: + print(f" - {row['email']}: {err}") + print() + print(f"{'provider':<12}{'total':>8}{'sent':>8}{'opened':>8}{'sent%':>8}{'open%':>8}") + for prov, c in sorted(by_provider.items()): + total = c["total"] or 1 + print( + f"{prov:<12}{c['total']:>8}{c['sent']:>8}{c['opened']:>8}" + f"{100 * c['sent'] / total:>7.0f}%{100 * c['opened'] / total:>7.0f}%" + ) + print( + "\nNote: 'opened' captures only recipients who clicked the link or " + "whose mail client loaded the tracking pixel within the poll window. " + "It is NOT a spam-folder check. Inspect test mailboxes you own to " + "verify inbox placement." + ) + return 0 if not send_errors else 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "recipients.csv")) diff --git a/deploy/deliverability/upload_template.py b/deploy/deliverability/upload_template.py new file mode 100644 index 00000000..08704c31 --- /dev/null +++ b/deploy/deliverability/upload_template.py @@ -0,0 +1,60 @@ +"""Upload the test service-agreement PDF to DocuSeal as a template. + +Prints the resulting template_id so you can plug it straight into send_batch.py. + +Usage: + DOCUSEAL_URL=https://e-sign.360dmmc.com \\ + DOCUSEAL_API_KEY=xxxxx \\ + python deploy/deliverability/upload_template.py [path-to-pdf] +""" + +import base64 +import os +import sys +from pathlib import Path + +import requests + +URL = os.environ["DOCUSEAL_URL"].rstrip("/") +KEY = os.environ["DOCUSEAL_API_KEY"] + +DEFAULT_PDF = Path(__file__).parent.parent / "test-fixtures" / "service_agreement.pdf" +pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PDF +if not pdf_path.exists(): + sys.exit(f"PDF not found: {pdf_path}") + +with pdf_path.open("rb") as f: + pdf_b64 = base64.b64encode(f.read()).decode("ascii") + +# DocuSeal POST /api/templates/pdf: +# https://www.docuseal.com/docs/api#create-a-template-from-pdf +payload = { + "name": "Smoke Test Service Agreement", + "documents": [ + { + "name": pdf_path.stem, + "file": pdf_b64, + "fields": [ + {"name": "Client Signature", "type": "signature", "role": "Client", + "areas": [{"x": 0.15, "y": 0.78, "w": 0.35, "h": 0.04, "page": 0}]}, + {"name": "Client Name", "type": "text", "role": "Client", + "areas": [{"x": 0.15, "y": 0.74, "w": 0.35, "h": 0.03, "page": 0}]}, + {"name": "Client Date", "type": "date", "role": "Client", + "areas": [{"x": 0.15, "y": 0.82, "w": 0.20, "h": 0.03, "page": 0}]}, + ], + } + ], +} + +r = requests.post( + f"{URL}/api/templates/pdf", + json=payload, + headers={"X-Auth-Token": KEY, "Content-Type": "application/json"}, + timeout=60, +) +r.raise_for_status() +tpl = r.json() +print(f"template_id={tpl['id']}") +print(f"name={tpl.get('name')}") +print(f"slug={tpl.get('slug')}") +print(f"\nNext: TEMPLATE_ID={tpl['id']} python deploy/deliverability/send_batch.py recipients.csv") diff --git a/deploy/docker-compose.behind-proxy.yml b/deploy/docker-compose.behind-proxy.yml new file mode 100644 index 00000000..11c2ac7b --- /dev/null +++ b/deploy/docker-compose.behind-proxy.yml @@ -0,0 +1,34 @@ +services: + app: + restart: unless-stopped + environment: + - FORCE_SSL=${HOST} + - DATABASE_URL=${DATABASE_URL} + - SECRET_KEY_BASE=${SECRET_KEY_BASE} + - SMTP_ADDRESS=${SMTP_ADDRESS} + - SMTP_PORT=${SMTP_PORT} + - SMTP_DOMAIN=${SMTP_DOMAIN} + - SMTP_USERNAME=${SMTP_USERNAME} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION} + - SMTP_ENABLE_STARTTLS=${SMTP_ENABLE_STARTTLS} + volumes: + - /opt/docuseal/data:/data/docuseal + ports: !override + - 127.0.0.1:3000:3000 + deploy: + resources: + limits: + memory: 2g + + postgres: + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - /opt/docuseal/pg_data:/var/lib/postgresql/18/docker + + caddy: + profiles: ["disabled"] diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 00000000..47baf898 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,34 @@ +services: + app: + restart: unless-stopped + environment: + - FORCE_SSL=${HOST} + - DATABASE_URL=${DATABASE_URL} + - SECRET_KEY_BASE=${SECRET_KEY_BASE} + - SMTP_ADDRESS=${SMTP_ADDRESS} + - SMTP_PORT=${SMTP_PORT} + - SMTP_DOMAIN=${SMTP_DOMAIN} + - SMTP_USERNAME=${SMTP_USERNAME} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION} + - SMTP_ENABLE_STARTTLS=${SMTP_ENABLE_STARTTLS} + volumes: + - /opt/docuseal/data:/data/docuseal + deploy: + resources: + limits: + memory: 2g + + postgres: + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - /opt/docuseal/pg_data:/var/lib/postgresql/18/docker + + caddy: + restart: unless-stopped + volumes: + - /opt/docuseal/caddy:/data/caddy diff --git a/deploy/health_check.sh b/deploy/health_check.sh new file mode 100644 index 00000000..3d274303 --- /dev/null +++ b/deploy/health_check.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Post-deploy smoke check. Returns non-zero if anything looks wrong. +# Usage: deploy/health_check.sh [host] + +set -uo pipefail + +HOST="${1:-$(grep ^HOST= deploy/.env 2>/dev/null | cut -d= -f2)}" +HOST="${HOST:-e-sign.360dmmc.com}" +FAIL=0 + +check() { + local label="$1"; shift + if "$@" >/dev/null 2>&1; then + echo " ✓ $label" + else + echo " ✗ $label" + FAIL=1 + fi +} + +echo "== containers ==" +check "app running" bash -c "docker compose ps app | grep -q ' Up'" +check "postgres healthy" bash -c "docker compose ps postgres | grep -q 'healthy'" + +echo "== database ==" +check "pg_isready" docker compose exec -T postgres pg_isready -U "${POSTGRES_USER:-docuseal}" + +echo "== app ==" +check "puma listening on 3000" docker compose exec -T app bash -c "ss -tln 2>/dev/null | grep -q ':3000' || netstat -tln 2>/dev/null | grep -q ':3000'" + +echo "== HTTP ==" +code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$HOST/" --max-time 10 || echo "000") +if [ "$code" = "200" ] || [ "$code" = "302" ] || [ "$code" = "301" ]; then + echo " ✓ https://$HOST/ -> $code" +else + echo " ✗ https://$HOST/ -> $code" + FAIL=1 +fi + +echo "== TLS ==" +if echo | openssl s_client -connect "$HOST:443" -servername "$HOST" -verify_return_error 2>/dev/null | grep -q "Verify return code: 0"; then + echo " ✓ TLS cert valid" +else + echo " ✗ TLS cert invalid or unreachable" + FAIL=1 +fi + +if [ "${DOCUSEAL_API_KEY:-}" != "" ]; then + echo "== API ==" + api_code=$(curl -sk -o /dev/null -w "%{http_code}" \ + -H "X-Auth-Token: $DOCUSEAL_API_KEY" \ + "https://$HOST/api/templates?limit=1" --max-time 10 || echo "000") + if [ "$api_code" = "200" ]; then + echo " ✓ API token valid" + else + echo " ✗ API check returned $api_code" + FAIL=1 + fi +else + echo " - skipped API check (set DOCUSEAL_API_KEY to enable)" +fi + +echo +[ $FAIL -eq 0 ] && echo "ALL OK" || echo "FAILURES PRESENT" +exit $FAIL diff --git a/deploy/proxy-snippets/Caddyfile.snippet b/deploy/proxy-snippets/Caddyfile.snippet new file mode 100644 index 00000000..601a1438 --- /dev/null +++ b/deploy/proxy-snippets/Caddyfile.snippet @@ -0,0 +1,11 @@ +# Drop into the existing Caddyfile. + +e-sign.360dmmc.com { + reverse_proxy 127.0.0.1:3000 { + header_up X-Forwarded-Proto https + header_up Host {host} + } + request_body { + max_size 50MB + } +} diff --git a/deploy/proxy-snippets/nginx.conf b/deploy/proxy-snippets/nginx.conf new file mode 100644 index 00000000..1af55cfa --- /dev/null +++ b/deploy/proxy-snippets/nginx.conf @@ -0,0 +1,34 @@ +# Drop into /etc/nginx/sites-available/e-sign.360dmmc.com and symlink to sites-enabled. +# Assumes Let's Encrypt cert obtained separately (e.g. certbot --nginx -d e-sign.360dmmc.com) +# or terminate TLS at an upstream load balancer and run this server block on :80. + +server { + listen 80; + listen [::]:80; + server_name e-sign.360dmmc.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name e-sign.360dmmc.com; + + ssl_certificate /etc/letsencrypt/live/e-sign.360dmmc.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/e-sign.360dmmc.com/privkey.pem; + + client_max_body_size 50m; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + 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 https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} diff --git a/deploy/proxy-snippets/traefik-labels.yml b/deploy/proxy-snippets/traefik-labels.yml new file mode 100644 index 00000000..7a1378e3 --- /dev/null +++ b/deploy/proxy-snippets/traefik-labels.yml @@ -0,0 +1,17 @@ +# If KVM1's existing reverse proxy is Traefik, ADD these labels to the `app` +# service in docker-compose.behind-proxy.yml (and remove the `ports:` mapping +# so the app stays on the docker network only). +# +# Adjust `traefik-public` to match the existing Traefik network name. + +labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.docuseal.rule=Host(`e-sign.360dmmc.com`)" + - "traefik.http.routers.docuseal.entrypoints=websecure" + - "traefik.http.routers.docuseal.tls.certresolver=letsencrypt" + - "traefik.http.services.docuseal.loadbalancer.server.port=3000" + +networks: + - traefik-public + - default diff --git a/deploy/restore.sh b/deploy/restore.sh new file mode 100644 index 00000000..5194f135 --- /dev/null +++ b/deploy/restore.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Restore a backup directory produced by backup.sh. +# Usage: ./deploy/restore.sh /opt/docuseal/backups/YYYYMMDD-HHMMSS + +set -euo pipefail + +SRC="${1:?usage: restore.sh }" +[ -f "$SRC/postgres.dump" ] || { echo "missing $SRC/postgres.dump"; exit 1; } + +read -r -p "Restore $SRC into running stack? Postgres data will be REPLACED. [yes/N] " ok +[ "$ok" = "yes" ] || exit 1 + +docker compose exec -T postgres \ + psql -U "${POSTGRES_USER:-docuseal}" -d postgres -c \ + "DROP DATABASE IF EXISTS ${POSTGRES_DB:-docuseal}; CREATE DATABASE ${POSTGRES_DB:-docuseal};" + +docker compose exec -T postgres \ + pg_restore -U "${POSTGRES_USER:-docuseal}" -d "${POSTGRES_DB:-docuseal}" --clean --if-exists \ + < "$SRC/postgres.dump" + +if [ -f "$SRC/attachments.tgz" ]; then + tar xzf "$SRC/attachments.tgz" -C /opt/docuseal +fi + +docker compose restart app +echo "restore complete from $SRC" diff --git a/deploy/test-fixtures/gen_service_agreement.py b/deploy/test-fixtures/gen_service_agreement.py new file mode 100644 index 00000000..b7c27693 --- /dev/null +++ b/deploy/test-fixtures/gen_service_agreement.py @@ -0,0 +1,46 @@ +"""Generate a placeholder service-agreement PDF for DocuSeal smoke testing. + +This is fictional sample text only. Not legal advice, not a real contract. +""" + +from pathlib import Path + +from reportlab.lib.pagesizes import LETTER +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer + +OUT = Path(__file__).parent / "service_agreement.pdf" + +styles = getSampleStyleSheet() +h1, body = styles["Title"], styles["BodyText"] + +story = [ + Paragraph("PROFESSIONAL SERVICES AGREEMENT", h1), + Spacer(1, 18), + Paragraph( + "This Agreement (\"Agreement\") is entered into as of the date last " + "signed below (\"Effective Date\") between 360DMMC LLC " + "(\"Provider\") and the undersigned Client.", + body, + ), + Spacer(1, 12), + Paragraph("1. Services. Provider agrees to deliver the services described in any mutually executed statement of work.", body), + Paragraph("2. Fees. Client shall pay Provider the fees set forth in each statement of work, net 30 days from invoice.", body), + Paragraph("3. Term. This Agreement begins on the Effective Date and continues until terminated by either party with 30 days written notice.", body), + Paragraph("4. Confidentiality. Each party agrees to protect the other's confidential information using reasonable care for two (2) years following disclosure.", body), + Paragraph("5. Limitation of Liability. Neither party shall be liable for indirect, incidental, or consequential damages arising out of this Agreement.", body), + Paragraph("6. Governing Law. This Agreement is governed by the laws of the jurisdiction in which Provider is incorporated.", body), + Spacer(1, 24), + Paragraph("SIGNATURES", body), + Spacer(1, 24), + Paragraph("Provider name: ____________________________", body), + Paragraph("Provider signature: ________________________", body), + Paragraph("Date: ____________________________________", body), + Spacer(1, 18), + Paragraph("Client name: ______________________________", body), + Paragraph("Client signature: __________________________", body), + Paragraph("Date: ____________________________________", body), +] + +SimpleDocTemplate(str(OUT), pagesize=LETTER).build(story) +print(f"wrote {OUT} ({OUT.stat().st_size} bytes)") diff --git a/deploy/test-fixtures/service_agreement.pdf b/deploy/test-fixtures/service_agreement.pdf new file mode 100644 index 00000000..8b91e0b1 --- /dev/null +++ b/deploy/test-fixtures/service_agreement.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260507163553+01'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260507163553+01'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1105 +>> +stream +GatUr>Ar7U&;B$?.h;-Uc6?77=h\G(dSpMS3,'=Iqda)#9-\ep&t+iG^&Ia6b*U$_aK=b5!?R^dk;tIF'u5TcTRNaan0u2o])Y-Q,Y&Dk&X"f;Dh[QZ#'*:=[f.Ilp\6niR__U.D##2XJeKT@BGqs3j?]6H/Yq!rM_`$g*6rbrg;E-)%!a,fahEf&i^P!AKqrLu8cI;=8c'@IgZ+oBY^*IZaD("BN/fPlQ.4)l2@[$)@Hge9S&8^_)'^=[nF(ghk(86XH+JYc?X"tGD!&Mdc$KeO9sViif/W)*kg+O5%#/^F`qKr'^tjT_eL+hJk[VusG-[@29:kC7r.]Z"i7]DcLh[-\KTeSL^9>fTu9=rfhPEpq.Q3JC"V!"82nIBLr/.g*="07M\_/n$^Q<>k/7OHYJWI9P`99b^D6r>L$s%us&b$_*=ui^;iB/)Os/5H+6j0kDZ3J;6X=I6H?5Z[E*PJqP'7OeCNR#@JjORm5>V[k":uWZBD-pb:,7SUSO7k["V5.-J*_!Ku1>?N\@X_Tac$B'PcT-ASfd]R7]Eu7tYmP],l]3Zq=HnGfFDRuGhM6^Ciu31[a"FZ*H5QDr:c9"PPZ%+^8qSA%kICV"HM,O5e`AO(V7E;t$Dr;@UrRL44==8@`+^D_ESL%7Rc.iDq&qHTs0f"&C=O,oBPfq`SINFbkk>86h^e.&Gendstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<502a944d1bd3e3265447450ba1cedc47><502a944d1bd3e3265447450ba1cedc47>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +2117 +%%EOF