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) <noreply@anthropic.com>
pull/674/head
dhia 1 month ago
parent 744d45d2c5
commit b1b4248720

2
.gitignore vendored

@ -38,3 +38,5 @@ yarn-debug.log*
/ee
dump.rdb
*.onnx
/pg_data

@ -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=

4
deploy/.gitignore vendored

@ -0,0 +1,4 @@
.env
backups/
__pycache__/
*.pyc

@ -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/<timestamp>
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)

@ -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))"

@ -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.<mode>.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 <<EOF
Next steps:
1. Open https://$(grep ^HOST= deploy/.env | cut -d= -f2)
2. Complete admin onboarding (email + password + company)
3. Settings -> 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

@ -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=<paste-token>
# 5. (Optional) export TEST_RECIPIENT=<your-real-email> 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" <<EOF
email,name,provider
$TEST_RECIPIENT,Local Validate,test
EOF
POLL_SECS=20 python deploy/deliverability/send_batch.py "$TMP_CSV" || rc=$?
rc=${rc:-0}
echo
if [ "$rc" -eq 0 ]; then
echo "LOCAL VALIDATION PASSED. Scripts work against a live DocuSeal stack."
else
echo "LOCAL VALIDATION FAILED (rc=$rc). Fix script issues before pointing at prod."
fi
exit "$rc"

@ -0,0 +1,7 @@
email,name,provider
test1@gmail.com,Gmail Test 1,gmail
test2@gmail.com,Gmail Test 2,gmail
test1@outlook.com,Outlook Test 1,outlook
test2@hotmail.com,Hotmail Test 1,outlook
test1@yahoo.com,Yahoo Test 1,yahoo
test2@yahoo.com,Yahoo Test 2,yahoo
1 email name provider
2 test1@gmail.com Gmail Test 1 gmail
3 test2@gmail.com Gmail Test 2 gmail
4 test1@outlook.com Outlook Test 1 outlook
5 test2@hotmail.com Hotmail Test 1 outlook
6 test1@yahoo.com Yahoo Test 1 yahoo
7 test2@yahoo.com Yahoo Test 2 yahoo

@ -0,0 +1,129 @@
"""DocuSeal deliverability batch sender.
Reads a CSV of test recipients, sends one signing-request submission per row
through the deployed DocuSeal API, then polls for SMTP send confirmation and
open events. Reports per-provider results.
Usage:
DOCUSEAL_URL=https://e-sign.360dmmc.com \\
DOCUSEAL_API_KEY=xxxxx \\
TEMPLATE_ID=42 \\
python deploy/deliverability/send_batch.py recipients.csv
What this DOES verify:
- SMTP accepted the message (DocuSeal records send result per submitter)
- Recipient opened the signing link within the polling window
What this does NOT verify (requires manual inspection of mailboxes you own):
- Inbox vs Spam folder placement
- DKIM/SPF/DMARC header alignment
- Mail-tester.com style content scoring (use that separately for one address)
"""
import csv
import os
import sys
import time
from collections import Counter, defaultdict
import requests
URL = os.environ["DOCUSEAL_URL"].rstrip("/")
KEY = os.environ["DOCUSEAL_API_KEY"]
TEMPLATE_ID = int(os.environ["TEMPLATE_ID"])
POLL_SECS = int(os.environ.get("POLL_SECS", "180"))
S = requests.Session()
S.headers.update({"X-Auth-Token": KEY, "Content-Type": "application/json"})
def create_submission(email: str, name: str) -> 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"))

@ -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")

@ -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"]

@ -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

@ -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

@ -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
}
}

@ -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;
}
}

@ -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

@ -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 <backup-dir>}"
[ -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"

@ -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 <strong>360DMMC LLC</strong> "
"(\"Provider\") and the undersigned <strong>Client</strong>.",
body,
),
Spacer(1, 12),
Paragraph("<strong>1. Services.</strong> Provider agrees to deliver the services described in any mutually executed statement of work.", body),
Paragraph("<strong>2. Fees.</strong> Client shall pay Provider the fees set forth in each statement of work, net 30 days from invoice.", body),
Paragraph("<strong>3. Term.</strong> This Agreement begins on the Effective Date and continues until terminated by either party with 30 days written notice.", body),
Paragraph("<strong>4. Confidentiality.</strong> Each party agrees to protect the other's confidential information using reasonable care for two (2) years following disclosure.", body),
Paragraph("<strong>5. Limitation of Liability.</strong> Neither party shall be liable for indirect, incidental, or consequential damages arising out of this Agreement.", body),
Paragraph("<strong>6. Governing Law.</strong> This Agreement is governed by the laws of the jurisdiction in which Provider is incorporated.", body),
Spacer(1, 24),
Paragraph("<strong>SIGNATURES</strong>", 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)")

@ -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"<sV"NGFr?jj[JDPF)Zu!Fa>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[-\KTeS<AIBdZHmX=dl%N:a^PI_+9c8_X,:hF>L^9>fTu9=rfhPEpq.Q3JC"V!"82nIBLr/.g*="07M\_/n$^Q<>k/7OHYJWI9P`99b^D6r>L$s%us&b$_<TX0/FLC[jaUIiCtkrF9=M3i!;#(\gkBdmp]Wi!V^'d4r(pmk?Op'R1552ctj$4'Y,g\^GZ`YXkRr*==e!=--t?nWA[)6*=2oQ?1"oD7P$An0hO`H4LtsFZ^qFb<qr9bqYn@9*6JUT%u-)#;]B.ZE2-?P*QJXdNqQFRPe6"fZm%J7;0;\lPJ=/(3ZD)>*=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]3Z<bRO3JK(3I,'ISp[)/1oRp&F,H;nhAumGh=(%HI7Yp%28ipMk]iHXr[d]\5`KUdak1UnWeK]!VAull]HOb/D_nDM&QEKFJ/5D3c)bfQ$X0He!0>q=HnGfFDRuGhM6^Ciu31[a"FZ*H5QDr:c9"PPZ%+^8qSA%kICV"HM,O5e`AO(V7E;t$Dr;@UrRL44==8@<P+8nUD@(5&0>`+^D_ESL%7Rc.iDq&qHTs0f"&C=O,oBPfq`SINFbkk>86h^e.&G<qJ(UUo*:A:Gb;)gOF$8tuCkrtUchE,3UcL(g=rrW=CpcS~>endstream
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
Loading…
Cancel
Save