Initial commit — Performance West telecom compliance platform

Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
justin 2026-04-27 06:54:22 -05:00
commit f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions

View file

@ -0,0 +1,9 @@
all:
children:
pw:
hosts:
pw-server:
ansible_host: 207.174.124.71
ansible_user: root
ansible_port: 22022
ansible_python_interpreter: /usr/bin/python3

View file

@ -0,0 +1,109 @@
---
# Performance West — Global Ansible Variables
# ── Domains ───────────────────────────────────────────────────────────────────
domain: performancewest.net
api_domain: api.performancewest.net
portal_domain: portal.performancewest.net
crm_domain: crm.performancewest.net
analytics_domain: analytics.performancewest.net
listmonk_domain: lists.performancewest.net
dev_domain: dev.performancewest.net
dev_api_domain: api.dev.performancewest.net
shkeeper_domain: pay.performancewest.net
shkeeper_admin_domain: crypto.performancewest.net
minio_domain: minio.performancewest.net
minio_console_domain: minio-console.performancewest.net
# Windows DocServer VM (connects to MinIO externally for DOCX→PDF conversion)
docserver_ip: 108.181.102.34
# mautic retired — replaced by Listmonk; kept so old templates don't break
mautic_domain: mail.performancewest.net
deploy_user: deploy
ssh_port: 22022
project_dir: /opt/performancewest
dev_project_dir: /opt/performancewest-dev
# ── Ports (internal — host-bound by Docker or k3s) ───────────────────────────
site_port: 4322
api_port: 3001
erpnext_port: 8080
listmonk_port: 9100 # Docker maps 9100→9000 inside container
umami_port: 3100
minio_port: 9000
minio_console_port: 9001
shkeeper_port: 5000 # SHKeeper web UI (k3s NodePort)
dev_site_port: 4323
dev_api_port: 3002
# ── Let's Encrypt ─────────────────────────────────────────────────────────────
certbot_email: info@performancewest.net
certbot_webroot: /var/www/certbot
# ── PostgreSQL (API DB) ───────────────────────────────────────────────────────
pg_database: performancewest
pg_user: pw
pg_password: "{{ vault_db_password }}"
pg_backup_dir: /opt/backups/postgresql
pg_backup_retention_days: 30
# Backwards-compat aliases used by some roles
db_name: "{{ pg_database }}"
db_user: "{{ pg_user }}"
db_password: "{{ pg_password }}"
# ── ERPNext ───────────────────────────────────────────────────────────────────
erpnext_db_password: "{{ vault_erpnext_db_password }}"
erpnext_admin_password: "{{ vault_erpnext_admin_password }}"
# ── Umami ─────────────────────────────────────────────────────────────────────
umami_db_password: "{{ vault_umami_db_password }}"
umami_app_secret: "{{ vault_umami_app_secret }}"
# ── MinIO ─────────────────────────────────────────────────────────────────────
minio_access_key: "{{ vault_minio_access_key }}"
minio_secret_key: "{{ vault_minio_secret_key }}"
minio_bucket: performancewest
# ── Ollama ────────────────────────────────────────────────────────────────────
ollama_model: "{{ vault_ollama_model | default('qwen2.5:7b') }}"
# ── Transactional email (Carbonio — co.carrierone.com) ────────────────────────
# Used by: API (nodemailer), Python workers (smtplib), ERPNext notifications
# NOT used by Listmonk — Listmonk has its own SMTP configured in its admin UI
smtp_host: co.carrierone.com
smtp_port: 587
smtp_user: noreply@performancewest.net
smtp_pass: "{{ vault_smtp_pass }}"
smtp_from: "Performance West <noreply@performancewest.net>"
smtp_admin_email: ops@performancewest.net
# ── Listmonk (mass-mail via SMTP2GO) ─────────────────────────────────────────
# Listmonk SMTP is configured via its web admin UI, not env vars.
# These vars are kept here for documentation and manual reference.
listmonk_smtp_host: mail.smtp2go.com
listmonk_smtp_port: 587
listmonk_smtp_user: "{{ vault_listmonk_smtp_user | default(smtp_user) }}"
listmonk_smtp_pass: "{{ vault_listmonk_smtp_pass }}"
listmonk_admin_user: "{{ vault_listmonk_admin_user }}"
listmonk_admin_password: "{{ vault_listmonk_admin_password }}"
# ── Common packages ───────────────────────────────────────────────────────────
common_packages:
- curl
- wget
- git
- htop
- unzip
- jq
- rsync
- ufw
- fail2ban
- chrony
- unattended-upgrades
- apt-transport-https
- ca-certificates
- gnupg
- python3
- python3-pip

View file

@ -0,0 +1,9 @@
all:
children:
pw:
hosts:
pw-server:
ansible_host: 207.174.124.71
ansible_user: deploy
ansible_port: 22022
ansible_python_interpreter: /usr/bin/python3

View file

@ -0,0 +1,15 @@
---
# Performance West — Bootstrap Security
# Run ONCE on a fresh Debian server as root to harden SSH, create deploy user, set up firewall.
#
# Usage:
# ansible-playbook playbooks/bootstrap.yml -i inventory/bootstrap.yml
#
# After this completes, all future playbooks use inventory/hosts.yml (as deploy user).
- name: Bootstrap server security
hosts: pw
become: true
roles:
- common
- docker

View file

@ -0,0 +1,8 @@
---
# Deploy only the worker-crons role (systemd timers).
# Usage: ansible-playbook playbooks/deploy-crons.yml -i inventory/hosts.yml
- name: Deploy worker cron timers
hosts: pw
become: true
roles:
- role: "{{ playbook_dir }}/../roles/worker-crons"

View file

@ -0,0 +1,39 @@
---
# Performance West — Code Deploy (no infrastructure changes)
# Usage: ansible-playbook playbooks/deploy.yml -i inventory/hosts.yml
- name: Deploy Performance West
hosts: pw
become: true
tasks:
- name: Sync project files
ansible.builtin.synchronize:
src: "{{ playbook_dir }}/../../"
dest: "{{ project_dir }}/"
delete: true
rsync_opts:
- "--exclude=node_modules"
- "--exclude=dist"
- "--exclude=.git"
- "--exclude=site-old"
- "--exclude=.env"
- name: Rebuild and restart containers
community.docker.docker_compose_v2:
project_src: "{{ project_dir }}"
build: always
state: present
register: compose_result
- name: Show compose output
ansible.builtin.debug:
var: compose_result.actions
- name: Wait for API health
ansible.builtin.uri:
url: "http://localhost:{{ api_port }}/api/v1/status"
return_content: true
register: health
retries: 10
delay: 3
until: health.status == 200

View file

@ -0,0 +1,22 @@
---
# Run a specific SQL migration against the app database
# Usage: ansible-playbook playbooks/run-migrations.yml -e "migration=001_core_tables.sql"
- name: Run database migration
hosts: pw
become: true
tasks:
- name: Copy migration file
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../api/migrations/{{ migration }}"
dest: "/tmp/{{ migration }}"
- name: Execute migration
community.docker.docker_container_exec:
container: performancewest-postgres-1
command: psql -U {{ db_user }} -d {{ db_name }} -f /tmp/{{ migration }}
register: migration_result
- name: Show result
ansible.builtin.debug:
var: migration_result.stdout_lines

View file

@ -0,0 +1,34 @@
---
# Performance West — Full Stack Provisioning
# Provisions a fresh Debian 13 server from scratch.
#
# Usage:
# ansible-playbook playbooks/site.yml -i inventory/hosts.yml --ask-vault-pass
#
# Roles run in order:
# common — packages, deploy user, SSH hardening, UFW
# docker — Docker CE + compose plugin + performancewest.service systemd unit
# postgresql — API PostgreSQL + backup cron + migrations
# app — Express API container
# site — Astro static site container
# erpnext — ERPNext CRM + workers + scheduler (custom image with frappe_crypto + frappe_adyen)
# minio — MinIO object storage + bucket creation
# workers — Python job server + Ollama LLM
# shkeeper — k3s + Helm + SHKeeper (crypto payments: BTC/ETH/USDC/Polygon/TRX/BNB/LTC)
# nginx — nginx + certbot TLS for all domains + fail2ban
- name: Provision Performance West server
hosts: pw
become: true
roles:
- common
- docker
- postgresql
- app
- site
- erpnext
- minio
- workers
- worker-crons
- shkeeper
- nginx

View file

@ -0,0 +1,2 @@
---
api_port: 3001

View file

@ -0,0 +1,52 @@
---
- name: Create API deployment directory
ansible.builtin.file:
path: /opt/performancewest/api
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0755"
- name: Sync API source code
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../../api/"
dest: /opt/performancewest/api/
delete: true
rsync_opts:
- "--exclude=node_modules"
- "--exclude=.env"
- "--exclude=dist"
notify: Rebuild api container
- name: Deploy API .env file
ansible.builtin.template:
src: app.env.j2
dest: /opt/performancewest/api/.env
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0600"
notify: Rebuild api container
- name: Build API container
ansible.builtin.command:
cmd: docker compose build api
chdir: /opt/performancewest
register: api_build
changed_when: "'writing image' in api_build.stderr"
- name: Start API container
ansible.builtin.command:
cmd: docker compose up -d api
chdir: /opt/performancewest
register: api_start
changed_when: "'Started' in api_start.stderr or 'Creating' in api_start.stderr"
- name: Wait for API health check
ansible.builtin.uri:
url: "http://127.0.0.1:{{ api_port }}/health"
status_code: 200
timeout: 5
register: api_health
retries: 12
delay: 5
until: api_health.status == 200

View file

@ -0,0 +1,114 @@
# {{ ansible_managed }}
# Performance West — API + Workers environment variables
# Deployed to {{ project_dir }}/.env by Ansible (ansible-playbook site.yml)
# DO NOT edit this file directly on the server — edit the j2 template and re-run.
NODE_ENV=production
PORT={{ api_port }}
# ── Database (PostgreSQL) ─────────────────────────────────────────────────────
DATABASE_URL=postgresql://{{ pg_user }}:{{ pg_password }}@api-postgres:5432/{{ pg_database }}
DB_PASSWORD={{ pg_password }}
# ── Auth ──────────────────────────────────────────────────────────────────────
ADMIN_JWT_SECRET={{ vault_admin_jwt_secret }}
PW_INTERNAL_API_KEY={{ vault_pw_internal_api_key }}
WEBHOOK_SECRET={{ vault_webhook_secret }}
# ── ERPNext (CRM — source of truth) ──────────────────────────────────────────
ERPNEXT_URL=http://erpnext:8080
ERPNEXT_SITE_NAME={{ domain }}
ERPNEXT_API_KEY={{ vault_erpnext_api_key }}
ERPNEXT_API_SECRET={{ vault_erpnext_api_secret }}
ERPNEXT_DB_PASSWORD={{ erpnext_db_password }}
# ── MinIO (document storage) ─────────────────────────────────────────────────
MINIO_ENDPOINT=minio
MINIO_PORT=9000
MINIO_ACCESS_KEY={{ minio_access_key }}
MINIO_SECRET_KEY={{ minio_secret_key }}
MINIO_BUCKET={{ minio_bucket }}
# ── Stripe ───────────────────────────────────────────────────────────────────
STRIPE_SECRET_KEY={{ vault_stripe_secret_key }}
STRIPE_PUBLISHABLE_KEY={{ vault_stripe_publishable_key }}
STRIPE_WEBHOOK_SECRET={{ vault_stripe_webhook_secret }}
STRIPE_IDENTITY_WEBHOOK_SECRET={{ vault_stripe_identity_webhook_secret }}
# Test keys — used only when NODE_ENV != production
STRIPE_TEST_SECRET_KEY={{ vault_stripe_test_secret_key | default('') }}
STRIPE_TEST_WEBHOOK_SECRET={{ vault_stripe_test_webhook_secret | default('') }}
STRIPE_TEST_IDENTITY_WEBHOOK_SECRET={{ vault_stripe_test_identity_webhook_secret | default('') }}
# ── PayPal ───────────────────────────────────────────────────────────────────
PAYPAL_CLIENT_ID={{ vault_paypal_client_id | default('') }}
PAYPAL_CLIENT_SECRET={{ vault_paypal_client_secret | default('') }}
PAYPAL_API_URL=https://api-m.paypal.com
# ── SHKeeper (crypto payments) ────────────────────────────────────────────────
SHKEEPER_URL=http://127.0.0.1:5000
SHKEEPER_PUBLIC_URL=https://{{ shkeeper_admin_domain }}
SHKEEPER_API_KEY={{ vault_shkeeper_api_key | default('') }}
# ── Workers ───────────────────────────────────────────────────────────────────
WORKER_URL=http://workers:8090
# ── Transactional email — Carbonio (co.carrierone.com) ───────────────────────
# All transactional mail: order confirmations, worker notifications, ERPNext alerts.
# Listmonk mass-mail uses SMTP2GO — configured separately in the Listmonk admin UI.
SMTP_HOST={{ smtp_host }}
SMTP_PORT={{ smtp_port }}
SMTP_USER={{ smtp_user }}
SMTP_PASS={{ smtp_pass }}
SMTP_FROM={{ smtp_from }}
ADMIN_EMAIL={{ smtp_admin_email }}
# ── Listmonk (email marketing) ────────────────────────────────────────────────
LISTMONK_URL=http://listmonk:9000
LISTMONK_ADMIN_USER={{ listmonk_admin_user }}
LISTMONK_ADMIN_PASSWORD={{ listmonk_admin_password }}
# ── Umami analytics ──────────────────────────────────────────────────────────
UMAMI_DB_PASSWORD={{ umami_db_password }}
UMAMI_APP_SECRET={{ umami_app_secret }}
# ── Anytime Mailbox (IMAP for OTP auto-fetch) ─────────────────────────────────
ANYTIME_MAILBOX_IMAP_HOST={{ smtp_host }}
ANYTIME_MAILBOX_IMAP_PORT=993
ANYTIME_MAILBOX_IMAP_SSL=true
ANYTIME_MAILBOX_IMAP_USER={{ vault_anytime_mailbox_imap_user | default(smtp_user) }}
ANYTIME_MAILBOX_IMAP_PASS={{ vault_anytime_mailbox_imap_pass | default(smtp_pass) }}
ANYTIME_MAILBOX_IMAP_FOLDER=INBOX
ANYTIME_MAILBOX_OTP_SENDER_HINT=anytimemailbox
ANYTIME_MAILBOX_OTP_TIMEOUT_SECONDS=180
ANYTIME_MAILBOX_OTP_POLL_SECONDS=6
ANYTIME_MAILBOX_SIGNUP_EMAIL={{ vault_anytime_mailbox_signup_email | default('filings@performancewest.net') }}
ANYTIME_MAILBOX_SIGNUP_PHONE={{ vault_anytime_mailbox_signup_phone | default('+16025550123') }}
ANYTIME_MAILBOX_DEFAULT_PASSWORD={{ vault_anytime_mailbox_default_password | default('') }}
# ── Relay (ACH / card routing) ────────────────────────────────────────────────
RELAY_IMAP_HOST={{ vault_relay_imap_host | default('') }}
RELAY_IMAP_PORT={{ vault_relay_imap_port | default('993') }}
RELAY_IMAP_USER={{ vault_relay_imap_user | default('') }}
RELAY_IMAP_PASS={{ vault_relay_imap_pass | default('') }}
RELAY_IMAP_FOLDER={{ vault_relay_imap_folder | default('INBOX') }}
RELAY_FILING_CARD_ID={{ vault_relay_filing_card_id | default('') }}
CRYPTO_FILING_CARD_ID={{ vault_crypto_filing_card_id | default('') }}
# ── Porkbun (.ca domain registration) ────────────────────────────────────────
PORKBUN_API_KEY={{ vault_porkbun_api_key | default('') }}
PORKBUN_SECRET_KEY={{ vault_porkbun_secret_key | default('') }}
# ── Flowroute (Canadian DID provisioning) ────────────────────────────────────
FLOWROUTE_ACCESS_KEY={{ vault_flowroute_access_key | default('') }}
FLOWROUTE_SECRET_KEY={{ vault_flowroute_secret_key | default('') }}
# ── HestiaCP (hosting provisioner) ───────────────────────────────────────────
HESTIA_URL={{ vault_hestia_url | default('https://cp.carrierone.com:8083') }}
HESTIA_USER={{ vault_hestia_user | default('admin') }}
HESTIA_PASS={{ vault_hestia_pass | default('') }}
# ── Application URLs ──────────────────────────────────────────────────────────
DOMAIN=https://{{ domain }}
SITE_URL=https://{{ domain }}
API_URL=https://{{ api_domain }}
PORTAL_URL=https://{{ portal_domain }}

View file

@ -0,0 +1,20 @@
---
common_packages:
- curl
- wget
- git
- htop
- unzip
- jq
- rsync
- ufw
- fail2ban
- chrony
- unattended-upgrades
- apt-transport-https
- ca-certificates
- gnupg
- python3
- python3-pip
deploy_user: deploy
ssh_port: 22022

View file

@ -0,0 +1,5 @@
---
- name: Restart sshd
ansible.builtin.systemd:
name: sshd
state: restarted

View file

@ -0,0 +1,122 @@
---
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Upgrade all packages
ansible.builtin.apt:
upgrade: safe
- name: Install common packages
ansible.builtin.apt:
name: "{{ common_packages }}"
state: present
- name: Create deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
shell: /bin/bash
groups: sudo
append: true
create_home: true
state: present
- name: Set authorized key for deploy user
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
state: present
- name: Allow deploy user passwordless sudo
ansible.builtin.copy:
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
dest: "/etc/sudoers.d/{{ deploy_user }}"
mode: "0440"
validate: "visudo -cf %s"
- name: Deploy hardened sshd_config
ansible.builtin.template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: "0600"
validate: "sshd -t -f %s"
notify: Restart sshd
- name: Configure UFW defaults - deny incoming
community.general.ufw:
direction: incoming
policy: deny
- name: Configure UFW defaults - allow outgoing
community.general.ufw:
direction: outgoing
policy: allow
- name: Allow SSH on custom port
community.general.ufw:
rule: allow
port: "{{ ssh_port }}"
proto: tcp
comment: "SSH custom port"
- name: Enable UFW
community.general.ufw:
state: enabled
- name: Enable and start chrony
ansible.builtin.systemd:
name: chrony
enabled: true
state: started
- name: Configure unattended-upgrades — origins
ansible.builtin.copy:
content: |
// Automatically install security updates and stable updates
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename},label=Debian";
"origin=Debian,codename=${distro_codename},label=Debian-Security";
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
"origin=Debian,codename=${distro_codename}-updates,label=Debian";
};
// Auto-remove unused kernel packages and dependencies
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
// Reboot at 4 AM if a kernel update requires it
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
// Email notification (optional — only if mail is configured)
// Unattended-Upgrade::Mail "root";
// Log to syslog
Unattended-Upgrade::SyslogEnable "true";
dest: /etc/apt/apt.conf.d/50unattended-upgrades
owner: root
group: root
mode: "0644"
- name: Configure auto-update schedule
ansible.builtin.copy:
content: |
// Run apt update and unattended-upgrade daily
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
dest: /etc/apt/apt.conf.d/20auto-upgrades
owner: root
group: root
mode: "0644"
- name: Enable and start unattended-upgrades
ansible.builtin.systemd:
name: unattended-upgrades
enabled: true
state: started

View file

@ -0,0 +1,46 @@
# {{ ansible_managed }}
# Hardened SSH configuration for Performance West
Port {{ ssh_port }}
AddressFamily inet
ListenAddress 0.0.0.0
# Authentication
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
# Only allow the deploy user
AllowUsers {{ deploy_user }}
# Disable unused auth methods
GSSAPIAuthentication no
KerberosAuthentication no
HostbasedAuthentication no
PermitEmptyPasswords no
# Session settings
MaxAuthTries 3
MaxSessions 5
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
# Disable forwarding (not needed for this server)
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
# Logging
SyslogFacility AUTH
LogLevel VERBOSE
# Misc
PrintMotd no
AcceptEnv LANG LC_*
# Use internal sftp
Subsystem sftp internal-sftp

View file

@ -0,0 +1,2 @@
---
docker_compose_version: "v2.30.0"

View file

@ -0,0 +1,15 @@
---
- name: Restart docker
ansible.builtin.systemd:
name: docker
state: restarted
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Enable performancewest service
ansible.builtin.systemd:
name: performancewest
enabled: true
state: started

View file

@ -0,0 +1,85 @@
---
- name: Remove old Docker packages
ansible.builtin.apt:
name:
- docker
- docker-engine
- docker.io
- containerd
- runc
state: absent
- name: Install prerequisites for Docker repo
ansible.builtin.apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
state: present
- name: Add Docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Add Docker apt repository
ansible.builtin.apt_repository:
repo: >-
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/debian
{{ ansible_distribution_release }} stable
filename: docker
state: present
- name: Install Docker CE and plugins
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
notify: Restart docker
- name: Ensure Docker service is enabled and started
ansible.builtin.systemd:
name: docker
enabled: true
state: started
- name: Add deploy user to docker group
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: docker
append: true
- name: Verify Docker Compose plugin is available
ansible.builtin.command:
cmd: docker compose version
changed_when: false
register: docker_compose_check
failed_when: docker_compose_check.rc != 0
# --- Auto-start on reboot via systemd ---
- name: Deploy performancewest systemd service unit
ansible.builtin.template:
src: performancewest.service.j2
dest: /etc/systemd/system/performancewest.service
owner: root
group: root
mode: "0644"
notify:
- Reload systemd
- Enable performancewest service
- name: Ensure performancewest service is enabled and started
ansible.builtin.systemd:
name: performancewest
enabled: true
state: started
daemon_reload: true

View file

@ -0,0 +1,40 @@
[Unit]
Description=Performance West Docker Compose Stack
Documentation=https://performancewest.net
# Wait for Docker daemon AND full network (including DNS) before starting.
# network-online.target ensures the host has an IP + default route before
# Docker tries to resolve container hostnames or connect to external services.
After=docker.service network-online.target
Wants=network-online.target
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ project_dir }}
# Start: bring all services up detached; remove orphans from previous deploys
ExecStart=/usr/bin/docker compose up -d --remove-orphans
# Stop: graceful shutdown — 120 s drain gives ERPNext time to finish requests
ExecStop=/usr/bin/docker compose down --timeout 120
# Reload (systemctl reload performancewest): re-apply compose without full restart
ExecReload=/usr/bin/docker compose up -d --remove-orphans
# Give containers plenty of time to start (ERPNext + Frappe bench takes ~2 min)
TimeoutStartSec=300
TimeoutStopSec=180
# Restart on failure (e.g. Docker daemon crash), but not on clean stop
Restart=on-failure
RestartSec=30
User={{ deploy_user }}
Group={{ deploy_user }}
# Load .env so docker compose can expand all ${VAR} references
EnvironmentFile=-{{ project_dir }}/.env
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,2 @@
---
erpnext_port: 8080

View file

@ -0,0 +1,10 @@
---
- name: restart erpnext
community.docker.docker_compose_v2:
project_src: "{{ project_dir }}"
services:
- erpnext
- erpnext-worker-default
- erpnext-worker-short
- erpnext-scheduler
state: restarted

View file

@ -0,0 +1,68 @@
---
# Performance West — ERPNext role
# Uses frappe/erpnext:version-15 with MariaDB 10.6 (officially supported stack).
- name: Ensure ERPNext containers are running
community.docker.docker_compose_v2:
project_src: "{{ project_dir }}"
services:
- erpnext
- erpnext-worker-default
- erpnext-worker-short
- erpnext-scheduler
- erpnext-mariadb
- erpnext-redis
state: present
register: erpnext_compose
- name: Wait for MariaDB to accept connections
ansible.builtin.command:
cmd: >-
docker compose exec -T erpnext-mariadb
mysqladmin ping -u root -p{{ erpnext_db_password }} --silent
chdir: "{{ project_dir }}"
register: mariadb_ready
retries: 20
delay: 5
until: mariadb_ready.rc == 0
changed_when: false
- name: Wait for ERPNext gunicorn to be ready
ansible.builtin.uri:
url: "http://localhost:{{ erpnext_port }}"
return_content: false
status_code: [200, 302, 307, 308]
register: erpnext_health
retries: 30
delay: 10
until: erpnext_health.status in [200, 302, 307, 308]
- name: Initialize ERPNext site (first run only)
ansible.builtin.command:
cmd: >-
docker compose exec -T erpnext
bench new-site {{ domain }}
--db-host erpnext-mariadb
--db-port 3306
--db-name erpnext
--db-root-username root
--db-root-password {{ erpnext_db_password }}
--db-password {{ erpnext_db_password }}
--admin-password {{ vault_erpnext_admin_password }}
--install-app erpnext
chdir: "{{ project_dir }}"
creates: "{{ project_dir }}/erpnext-initialized"
register: erpnext_init
- name: Set ERPNext site as default
ansible.builtin.command:
cmd: docker compose exec -T erpnext bench use {{ domain }}
chdir: "{{ project_dir }}"
when: erpnext_init.changed
changed_when: true
- name: Mark ERPNext as initialized
ansible.builtin.file:
path: "{{ project_dir }}/erpnext-initialized"
state: touch
when: erpnext_init.changed

View file

@ -0,0 +1,4 @@
---
minio_access_key: "{{ vault_minio_access_key }}"
minio_secret_key: "{{ vault_minio_secret_key }}"
minio_bucket: performancewest

View file

@ -0,0 +1,48 @@
---
# Performance West — MinIO role
# Starts MinIO and creates the required bucket.
- name: Ensure MinIO container is running
community.docker.docker_compose_v2:
project_src: "{{ project_dir }}"
services:
- minio
state: present
register: minio_compose
- name: Wait for MinIO to be ready
ansible.builtin.uri:
url: "http://127.0.0.1:{{ minio_port }}/minio/health/live"
status_code: 200
timeout: 5
register: minio_health
retries: 12
delay: 5
until: minio_health.status == 200
- name: Install MinIO client (mc)
ansible.builtin.get_url:
url: https://dl.min.io/client/mc/release/linux-amd64/mc
dest: /usr/local/bin/mc
mode: "0755"
force: false
- name: Configure mc alias for local MinIO
ansible.builtin.command:
cmd: >-
mc alias set local
http://127.0.0.1:{{ minio_port }}
{{ minio_access_key }}
{{ minio_secret_key }}
changed_when: false
- name: Create performancewest bucket if not exists
ansible.builtin.command:
cmd: mc mb --ignore-existing local/{{ minio_bucket }}
register: bucket_create
changed_when: "'Bucket created' in bucket_create.stdout"
- name: Set bucket policy — private (no public access)
ansible.builtin.command:
cmd: mc anonymous set none local/{{ minio_bucket }}
changed_when: false

View file

@ -0,0 +1,3 @@
---
certbot_webroot: /var/www/certbot
certbot_email: info@performancewest.net

View file

@ -0,0 +1,10 @@
---
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
- name: Restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted

View file

@ -0,0 +1,447 @@
---
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Install certbot and nginx plugin
ansible.builtin.apt:
name:
- certbot
- python3-certbot-nginx
state: present
- name: Install fail2ban
ansible.builtin.apt:
name: fail2ban
state: present
- name: Create certbot webroot directory
ansible.builtin.file:
path: "{{ certbot_webroot }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: Create snippets directory
ansible.builtin.file:
path: /etc/nginx/snippets
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy shared security snippet
ansible.builtin.template:
src: pw-security.conf.j2
dest: /etc/nginx/snippets/pw-security.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
# ── Phase 1: HTTP-only configs for certbot bootstrap ─────────────────────────
- name: Deploy initial HTTP-only site config
ansible.builtin.template:
src: pw-site.conf.j2
dest: /etc/nginx/sites-available/pw-site.conf
owner: root
group: root
mode: "0644"
- name: Enable HTTP-only site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-site.conf
dest: /etc/nginx/sites-enabled/pw-site.conf
state: link
- name: Remove default nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
- name: Reload nginx for HTTP configs
ansible.builtin.systemd:
name: nginx
state: reloaded
# ── Phase 2: Obtain TLS certificates ─────────────────────────────────────────
- name: Obtain certificate for performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d performancewest.net -d www.performancewest.net
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/performancewest.net/fullchain.pem
- name: Obtain certificate for api.performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ api_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ api_domain }}/fullchain.pem
- name: Obtain certificate for portal.performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ portal_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ portal_domain }}/fullchain.pem
- name: Obtain certificate for crm.performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ crm_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ crm_domain }}/fullchain.pem
- name: Obtain certificate for lists.performancewest.net (Listmonk)
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ listmonk_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ listmonk_domain }}/fullchain.pem
- name: Obtain certificate for analytics.performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ analytics_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ analytics_domain }}/fullchain.pem
- name: Obtain certificate for dev.performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ dev_domain }} -d www.{{ dev_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ dev_domain }}/fullchain.pem
- name: Obtain certificate for api.dev.performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ dev_api_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ dev_api_domain }}/fullchain.pem
- name: Obtain certificate for pay.performancewest.net (SHKeeper API)
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ shkeeper_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ shkeeper_domain }}/fullchain.pem
- name: Obtain certificate for crypto.performancewest.net (SHKeeper Admin)
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ shkeeper_admin_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ shkeeper_admin_domain }}/fullchain.pem
- name: Obtain certificate for minio.performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ minio_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ minio_domain }}/fullchain.pem
- name: Obtain certificate for minio-console.performancewest.net
ansible.builtin.command:
cmd: >-
certbot certonly --webroot
-w {{ certbot_webroot }}
-d {{ minio_console_domain }}
--email {{ certbot_email }}
--agree-tos --non-interactive
creates: /etc/letsencrypt/live/{{ minio_console_domain }}/fullchain.pem
# ── Phase 3: Deploy TLS configs ───────────────────────────────────────────────
- name: Deploy TLS config for performancewest.net
ansible.builtin.template:
src: pw-site-tls.conf.j2
dest: /etc/nginx/sites-available/pw-site.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Deploy TLS config for api.performancewest.net
ansible.builtin.template:
src: pw-api-tls.conf.j2
dest: /etc/nginx/sites-available/pw-api.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable API site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-api.conf
dest: /etc/nginx/sites-enabled/pw-api.conf
state: link
- name: Deploy TLS config for portal.performancewest.net
ansible.builtin.template:
src: pw-portal-tls.conf.j2
dest: /etc/nginx/sites-available/pw-portal.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable portal site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-portal.conf
dest: /etc/nginx/sites-enabled/pw-portal.conf
state: link
- name: Deploy TLS config for crm.performancewest.net (ERPNext internal CRM)
ansible.builtin.template:
src: pw-support-tls.conf.j2
dest: /etc/nginx/sites-available/pw-crm.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable CRM site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-crm.conf
dest: /etc/nginx/sites-enabled/pw-crm.conf
state: link
- name: Remove deprecated Zammad/Mautic/support site configs
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/nginx/sites-available/pw-support.conf
- /etc/nginx/sites-enabled/pw-support.conf
- /etc/nginx/sites-available/pw-mautic.conf
- /etc/nginx/sites-enabled/pw-mautic.conf
notify: Reload nginx
- name: Deploy TLS config for lists.performancewest.net (Listmonk)
ansible.builtin.template:
src: pw-listmonk-tls.conf.j2
dest: /etc/nginx/sites-available/pw-listmonk.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable Listmonk site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-listmonk.conf
dest: /etc/nginx/sites-enabled/pw-listmonk.conf
state: link
- name: Deploy TLS config for analytics.performancewest.net
ansible.builtin.template:
src: pw-analytics-tls.conf.j2
dest: /etc/nginx/sites-available/pw-analytics.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable analytics site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-analytics.conf
dest: /etc/nginx/sites-enabled/pw-analytics.conf
state: link
- name: Deploy TLS config for dev.performancewest.net
ansible.builtin.template:
src: pw-dev-tls.conf.j2
dest: /etc/nginx/sites-available/pw-dev-site.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable dev site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-dev-site.conf
dest: /etc/nginx/sites-enabled/pw-dev-site.conf
state: link
- name: Deploy TLS config for api.dev.performancewest.net
ansible.builtin.template:
src: pw-dev-api-tls.conf.j2
dest: /etc/nginx/sites-available/pw-dev-api.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable dev API site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-dev-api.conf
dest: /etc/nginx/sites-enabled/pw-dev-api.conf
state: link
- name: Deploy TLS config for pay.performancewest.net (SHKeeper API)
ansible.builtin.template:
src: pw-btcpay-tls.conf.j2
dest: /etc/nginx/sites-available/pw-btcpay.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable SHKeeper API site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-btcpay.conf
dest: /etc/nginx/sites-enabled/pw-btcpay.conf
state: link
- name: Deploy TLS config for crypto.performancewest.net (SHKeeper Admin)
ansible.builtin.template:
src: pw-crypto-tls.conf.j2
dest: /etc/nginx/sites-available/pw-crypto.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable SHKeeper Admin site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-crypto.conf
dest: /etc/nginx/sites-enabled/pw-crypto.conf
state: link
- name: Deploy TLS config for minio.performancewest.net + console
ansible.builtin.template:
src: pw-minio-tls.conf.j2
dest: /etc/nginx/sites-available/pw-minio.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable MinIO site config
ansible.builtin.file:
src: /etc/nginx/sites-available/pw-minio.conf
dest: /etc/nginx/sites-enabled/pw-minio.conf
state: link
# ── Phase 4: Firewall & fail2ban ─────────────────────────────────────────────
- name: Allow HTTP through UFW
community.general.ufw:
rule: allow
port: "80"
proto: tcp
comment: HTTP
- name: Allow HTTPS through UFW
community.general.ufw:
rule: allow
port: "443"
proto: tcp
comment: HTTPS
- name: Deploy fail2ban nginx filter
ansible.builtin.copy:
content: |
[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD) .*(\.php|\.asp|wp-admin|wp-login|\.env|\.git).*" (400|403|404|444)
ignoreregex =
dest: /etc/fail2ban/filter.d/nginx-badbots.conf
owner: root
group: root
mode: "0644"
notify: Restart fail2ban
- name: Deploy fail2ban nginx jail
ansible.builtin.copy:
content: |
[nginx-badbots]
enabled = true
port = http,https
filter = nginx-badbots
logpath = /var/log/nginx/access.log
maxretry = 5
bantime = 3600
findtime = 600
dest: /etc/fail2ban/jail.d/nginx-badbots.conf
owner: root
group: root
mode: "0644"
notify: Restart fail2ban
- name: Deploy fail2ban pw-api filter
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../../fail2ban/filter.d/pw-api.conf"
dest: /etc/fail2ban/filter.d/pw-api.conf
owner: root
group: root
mode: "0644"
notify: Restart fail2ban
- name: Deploy fail2ban pw-api jail
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../../fail2ban/jail.d/pw.conf"
dest: /etc/fail2ban/jail.d/pw-api.conf
owner: root
group: root
mode: "0644"
notify: Restart fail2ban
- name: Enable and start fail2ban
ansible.builtin.systemd:
name: fail2ban
enabled: true
state: started
- name: Enable and start nginx
ansible.builtin.systemd:
name: nginx
enabled: true
state: started
# ── Phase 5: Certbot renewal ──────────────────────────────────────────────────
- name: Set up certbot renewal cron
ansible.builtin.cron:
name: "Certbot renewal"
minute: "30"
hour: "3"
job: "certbot renew --quiet --post-hook 'systemctl reload nginx'"
user: root

View file

@ -0,0 +1,51 @@
# {{ ansible_managed }}
# HTTPS config for analytics.performancewest.net (Umami)
# Redirect HTTP -> HTTPS
server {
listen 80;
server_name analytics.performancewest.net;
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
location / {
return 301 https://analytics.performancewest.net$request_uri;
}
}
# Umami analytics
server {
listen 443 ssl;
http2 on;
server_name analytics.performancewest.net;
ssl_certificate /etc/letsencrypt/live/analytics.performancewest.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/analytics.performancewest.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
include /etc/nginx/snippets/pw-security.conf;
location / {
proxy_pass http://127.0.0.1:{{ umami_port }};
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;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}

View file

@ -0,0 +1,75 @@
# {{ ansible_managed }}
# HTTPS config for api.performancewest.net
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# Redirect HTTP -> HTTPS
server {
listen 80;
server_name api.performancewest.net;
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
location / {
return 301 https://api.performancewest.net$request_uri;
}
}
# API server
server {
listen 443 ssl;
http2 on;
server_name api.performancewest.net;
ssl_certificate /etc/letsencrypt/live/api.performancewest.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.performancewest.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
include /etc/nginx/snippets/pw-security.conf;
# CORS headers
add_header Access-Control-Allow-Origin "https://performancewest.net" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
add_header Access-Control-Max-Age 86400 always;
location / {
# Handle CORS preflight
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://performancewest.net";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept";
add_header Access-Control-Max-Age 86400;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Rate limiting
limit_req zone=api_limit burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:{{ api_port }};
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;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}

View file

@ -0,0 +1,45 @@
{{ ansible_managed }}
# SHKeeper API — pay.performancewest.net
server {
listen 443 ssl;
http2 on;
server_name {{ shkeeper_domain }};
ssl_certificate /etc/letsencrypt/live/{{ shkeeper_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ shkeeper_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
include /etc/nginx/snippets/pw-security.conf;
client_max_body_size 10m;
proxy_http_version 1.1;
location / {
proxy_pass http://127.0.0.1:{{ shkeeper_port }};
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}
server {
listen 80;
server_name {{ shkeeper_domain }};
return 301 https://$host$request_uri;
}

View file

@ -0,0 +1,45 @@
{{ ansible_managed }}
# SHKeeper Admin Panel — crypto.performancewest.net
server {
listen 443 ssl;
http2 on;
server_name {{ shkeeper_admin_domain }};
ssl_certificate /etc/letsencrypt/live/{{ shkeeper_admin_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ shkeeper_admin_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
include /etc/nginx/snippets/pw-security.conf;
client_max_body_size 10m;
proxy_http_version 1.1;
location / {
proxy_pass http://127.0.0.1:{{ shkeeper_port }};
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}
server {
listen 80;
server_name {{ shkeeper_admin_domain }};
return 301 https://$host$request_uri;
}

View file

@ -0,0 +1,41 @@
# {{ ansible_managed }}
# HTTPS config for api.dev.performancewest.net (dev API — direct access)
server {
listen 80;
server_name {{ dev_api_domain }};
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
location / { return 301 https://{{ dev_api_domain }}$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name {{ dev_api_domain }};
ssl_certificate /etc/letsencrypt/live/{{ dev_api_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ dev_api_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:{{ dev_api_port }};
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 $scheme;
proxy_read_timeout 120s;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}

View file

@ -0,0 +1,52 @@
# {{ ansible_managed }}
# HTTPS config for dev.performancewest.net (dev stack — site + api combined)
# Dev site runs on port {{ dev_site_port }}, dev API on port {{ dev_api_port }}.
server {
listen 80;
server_name {{ dev_domain }} www.{{ dev_domain }};
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
location / { return 301 https://{{ dev_domain }}$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name {{ dev_domain }} www.{{ dev_domain }};
ssl_certificate /etc/letsencrypt/live/{{ dev_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ dev_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 50m;
# Proxy /api/* to the dev API container (avoids CORS issues in the browser)
location /api/ {
proxy_pass http://127.0.0.1:{{ dev_api_port }}/api/;
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 $scheme;
proxy_read_timeout 120s;
}
location / {
proxy_pass http://127.0.0.1:{{ dev_site_port }};
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 $scheme;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}

View file

@ -0,0 +1,43 @@
# {{ ansible_managed }}
# HTTPS config for lists.performancewest.net (Listmonk email marketing)
server {
listen 80;
server_name {{ listmonk_domain }};
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
location / { return 301 https://{{ listmonk_domain }}$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name {{ listmonk_domain }};
ssl_certificate /etc/letsencrypt/live/{{ listmonk_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ listmonk_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Large body for import files (subscriber CSV uploads)
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:{{ listmonk_port }};
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;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}

View file

@ -0,0 +1,26 @@
# Mautic Email Marketing — mail.performancewest.net
server {
listen 443 ssl http2;
server_name {{ mautic_domain }};
ssl_certificate /etc/letsencrypt/live/{{ mautic_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ mautic_domain }}/privkey.pem;
include /etc/nginx/conf.d/pw-security.conf;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:{{ mautic_port }};
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;
}
}
server {
listen 80;
server_name {{ mautic_domain }};
return 301 https://$host$request_uri;
}

View file

@ -0,0 +1,97 @@
{{ ansible_managed }}
# MinIO S3 API — minio.performancewest.net
# ACCESS RESTRICTED: only the Windows DocServer VM ({{ docserver_ip }}) and localhost.
# Docker containers reach MinIO via the internal Docker network (minio:9000),
# not through this nginx vhost. This endpoint is for external clients only.
server {
listen 443 ssl;
http2 on;
server_name {{ minio_domain }};
ssl_certificate /etc/letsencrypt/live/{{ minio_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ minio_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# IP allowlist — only the Windows DocServer VM and localhost
allow 127.0.0.1;
allow ::1;
allow {{ docserver_ip }};
deny all;
# Large body for document uploads
client_max_body_size 100m;
# Disable buffering for S3 streaming
proxy_buffering off;
proxy_request_buffering off;
location / {
proxy_pass http://127.0.0.1:{{ minio_port }};
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;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}
# MinIO Console — minio-console.performancewest.net
# Same IP restriction: admin console is never public.
server {
listen 443 ssl;
http2 on;
server_name {{ minio_console_domain }};
ssl_certificate /etc/letsencrypt/live/{{ minio_console_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ minio_console_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# IP allowlist
allow 127.0.0.1;
allow ::1;
allow {{ docserver_ip }};
deny all;
proxy_http_version 1.1;
location / {
proxy_pass http://127.0.0.1:{{ minio_console_port }};
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}
server {
listen 80;
server_name {{ minio_domain }} {{ minio_console_domain }};
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
location / { return 301 https://$host$request_uri; }
}

View file

@ -0,0 +1,47 @@
# {{ ansible_managed }}
# HTTPS config for portal.performancewest.net (ERPNext customer portal)
server {
listen 80;
server_name {{ portal_domain }};
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
location / { return 301 https://{{ portal_domain }}$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name {{ portal_domain }};
ssl_certificate /etc/letsencrypt/live/{{ portal_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ portal_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:{{ erpnext_port }};
proxy_http_version 1.1;
# Must match the Frappe site name so the right site is served
proxy_set_header Host {{ domain }};
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;
proxy_set_header X-Forwarded-Host {{ portal_domain }};
# WebSocket support (ERPNext real-time)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
proxy_buffering off;
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}

View file

@ -0,0 +1,48 @@
# {{ ansible_managed }}
# Shared security snippet - included by all server blocks
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Block common attack paths
location ~* \.(php|asp|aspx|cgi|pl)$ {
return 444;
}
location ~* /(wp-admin|wp-login|wp-content|wp-includes|wordpress) {
return 444;
}
location ~ /\.git {
return 444;
}
location ~ /\.env {
return 444;
}
location ~ /\.ht {
return 444;
}
location ~* /(phpmyadmin|pma|myadmin|mysql|adminer) {
return 444;
}
location ~* /(admin|administrator|login\.action|struts) {
return 444;
}
# Block hidden files and directories (except .well-known)
location ~ /\.(?!well-known) {
return 444;
}
# Block backup and config files
location ~* \.(bak|config|sql|fla|ini|log|sh|inc|swp|dist|old|save)$ {
return 444;
}

View file

@ -0,0 +1,87 @@
# {{ ansible_managed }}
# HTTPS config for performancewest.net
# Redirect HTTP -> HTTPS
server {
listen 80;
server_name performancewest.net www.performancewest.net;
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
location / {
return 301 https://performancewest.net$request_uri;
}
}
# Redirect www -> apex
server {
listen 443 ssl;
http2 on;
server_name www.performancewest.net;
ssl_certificate /etc/letsencrypt/live/performancewest.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/performancewest.net/privkey.pem;
return 301 https://performancewest.net$request_uri;
}
# Main site
server {
listen 443 ssl;
http2 on;
server_name performancewest.net;
ssl_certificate /etc/letsencrypt/live/performancewest.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/performancewest.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# HSTS - 1 year, include subdomains
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
include /etc/nginx/snippets/pw-security.conf;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
location / {
proxy_pass http://127.0.0.1:{{ site_port }};
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;
# Cache static assets
proxy_cache_valid 200 1h;
proxy_buffering on;
}
# Long cache for immutable assets
location /_astro/ {
proxy_pass http://127.0.0.1:{{ site_port }};
expires 1y;
add_header Cache-Control "public, immutable";
}
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
}

View file

@ -0,0 +1,20 @@
# {{ ansible_managed }}
# HTTP-only config for initial certbot provisioning
server {
listen 80;
server_name performancewest.net www.performancewest.net;
# Certbot ACME challenge
location /.well-known/acme-challenge/ {
root {{ certbot_webroot }};
}
location / {
proxy_pass http://127.0.0.1:{{ site_port }};
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;
}
}

View file

@ -0,0 +1,34 @@
# ERPNext CRM — crm.performancewest.net
server {
listen 443 ssl http2;
server_name {{ crm_domain }};
ssl_certificate /etc/letsencrypt/live/{{ crm_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ crm_domain }}/privkey.pem;
include /etc/nginx/conf.d/pw-security.conf;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:{{ erpnext_port }};
proxy_set_header Host {{ domain }}; # Frappe matches site by Host header — must match site dir name (performancewest.net)
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;
# WebSocket support (for ERPNext real-time)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
server {
listen 80;
server_name {{ crm_domain }};
return 301 https://$host$request_uri;
}

View file

@ -0,0 +1,3 @@
---
pg_backup_dir: /opt/backups/postgresql
pg_backup_retention_days: 30

View file

@ -0,0 +1,58 @@
---
- name: Create PostgreSQL data directory
ansible.builtin.file:
path: /opt/performancewest/pgdata
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0700"
- name: Create backup directory
ansible.builtin.file:
path: "{{ pg_backup_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Start PostgreSQL container
ansible.builtin.command:
cmd: docker compose up -d postgres
chdir: /opt/performancewest
register: pg_start
changed_when: "'Started' in pg_start.stderr or 'Creating' in pg_start.stderr"
- name: Wait for PostgreSQL to accept connections
ansible.builtin.command:
cmd: >-
docker compose exec -T postgres
pg_isready -U {{ pg_user }} -d {{ pg_database }}
chdir: /opt/performancewest
register: pg_ready
retries: 12
delay: 5
until: pg_ready.rc == 0
changed_when: false
- name: Run database migrations
ansible.builtin.command:
cmd: docker compose exec -T api npm run db:migrate
chdir: /opt/performancewest
register: db_migrate
changed_when: "'migrated' in db_migrate.stdout"
- name: Deploy PostgreSQL backup script
ansible.builtin.template:
src: pg-backup.sh.j2
dest: /usr/local/bin/pg-backup.sh
owner: root
group: root
mode: "0700"
- name: Set up daily PostgreSQL backup cron
ansible.builtin.cron:
name: "Daily PostgreSQL backup"
minute: "0"
hour: "2"
job: "/usr/local/bin/pg-backup.sh >> /var/log/pg-backup.log 2>&1"
user: root

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Daily PostgreSQL backup script
set -euo pipefail
BACKUP_DIR="{{ pg_backup_dir }}"
RETENTION_DAYS="{{ pg_backup_retention_days }}"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/pw_db_${TIMESTAMP}.sql.gz"
echo "[$(date --iso-8601=seconds)] Starting PostgreSQL backup..."
# Dump the database from the Docker container and compress
docker compose -f /opt/performancewest/docker-compose.yml \
exec -T api-postgres \
pg_dump -U {{ pg_user }} -d {{ pg_database }} --no-owner --no-privileges \
| gzip > "${BACKUP_FILE}"
# Verify the backup file is not empty
if [ ! -s "${BACKUP_FILE}" ]; then
echo "[$(date --iso-8601=seconds)] ERROR: Backup file is empty, removing."
rm -f "${BACKUP_FILE}"
exit 1
fi
BACKUP_SIZE="$(du -h "${BACKUP_FILE}" | cut -f1)"
echo "[$(date --iso-8601=seconds)] Backup completed: ${BACKUP_FILE} (${BACKUP_SIZE})"
# Prune backups older than retention period
PRUNED="$(find "${BACKUP_DIR}" -name "pw_db_*.sql.gz" -type f -mtime +${RETENTION_DAYS} -print -delete | wc -l)"
echo "[$(date --iso-8601=seconds)] Pruned ${PRUNED} backup(s) older than ${RETENTION_DAYS} days."
echo "[$(date --iso-8601=seconds)] Backup process finished."

View file

@ -0,0 +1,31 @@
---
# SFTPGo role defaults — Performance West CDR ingestion.
sftpgo_image: "drakkan/sftpgo:edge-alpine-slim"
sftpgo_sftp_port: 2022 # SFTP
sftpgo_ftps_port: 990 # FTPS implicit
sftpgo_passive_port_min: 50000
sftpgo_passive_port_max: 50100
sftpgo_admin_port: 8080 # internal-only admin API + web UI
sftpgo_hostname: "cdr.performancewest.net"
# MinIO backend for user home directories (cdr-uploads/{customer_id}/raw/)
sftpgo_minio_endpoint: "{{ minio_endpoint }}"
sftpgo_minio_bucket: "{{ minio_bucket }}"
sftpgo_minio_access_key: "{{ minio_access_key }}"
sftpgo_minio_secret_key: "{{ minio_secret_key }}"
# Postgres for SFTPGo's own data (users/logins/sessions). Reuses the
# existing cluster; SFTPGo gets its own database.
sftpgo_pg_host: "postgres"
sftpgo_pg_port: 5432
sftpgo_pg_database: "sftpgo"
sftpgo_pg_username: "sftpgo"
sftpgo_pg_password: "{{ sftpgo_pg_password_vault | default('CHANGEME_provision_via_vault') }}"
# Admin credentials for the SFTPGo REST API.
sftpgo_admin_user: "pw-admin"
sftpgo_admin_password: "{{ sftpgo_admin_password_vault | default('CHANGEME_provision_via_vault') }}"
# Per-user default quota (overridable at provisioning time)
sftpgo_default_quota_bytes: 5368709120 # 5 GB

View file

@ -0,0 +1,67 @@
---
# Performance West — SFTPGo role
# Deploys the SFTPGo SFTP/FTPS server configured to use MinIO as backend
# storage and Postgres as the user database. CDR-ingestion customers
# opt-in via the portal; the puller's sftpgo_provisioner worker calls
# the admin REST API to provision/deprovision users.
- name: Ensure SFTPGo config directory
ansible.builtin.file:
path: "{{ project_dir }}/sftpgo"
state: directory
mode: "0750"
- name: Render sftpgo.json
ansible.builtin.template:
src: sftpgo.json.j2
dest: "{{ project_dir }}/sftpgo/sftpgo.json"
mode: "0640"
notify: Restart SFTPGo
- name: Ensure sftpgo database + user (Postgres)
community.postgresql.postgresql_db:
name: "{{ sftpgo_pg_database }}"
login_host: "{{ sftpgo_pg_host }}"
port: "{{ sftpgo_pg_port }}"
login_user: postgres
state: present
- name: Ensure sftpgo Postgres user
community.postgresql.postgresql_user:
db: "{{ sftpgo_pg_database }}"
name: "{{ sftpgo_pg_username }}"
password: "{{ sftpgo_pg_password }}"
priv: "ALL"
login_host: "{{ sftpgo_pg_host }}"
login_user: postgres
state: present
- name: Start SFTPGo container via docker-compose
community.docker.docker_compose_v2:
project_src: "{{ project_dir }}"
services:
- sftpgo
state: present
- name: Wait for SFTPGo admin API to be ready
ansible.builtin.uri:
url: "http://127.0.0.1:{{ sftpgo_admin_port }}/healthz"
status_code: 200
timeout: 5
register: sftpgo_health
retries: 12
delay: 5
until: sftpgo_health.status == 200
- name: Create initial SFTPGo admin user (idempotent — errors on exists)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ sftpgo_admin_port }}/api/v2/admins"
method: POST
body_format: json
body:
username: "{{ sftpgo_admin_user }}"
password: "{{ sftpgo_admin_password }}"
status: 1
permissions: ["*"]
description: "Performance West provisioning admin"
status_code: [201, 409] # 409 = already exists

View file

@ -0,0 +1,79 @@
{
"sftpd": {
"bindings": [
{
"address": "0.0.0.0",
"port": {{ sftpgo_sftp_port }}
}
],
"max_auth_tries": 3,
"banner": "Performance West CDR Ingestion — authorized use only",
"host_keys": ["/var/lib/sftpgo/host_keys/ssh_host_ed25519_key",
"/var/lib/sftpgo/host_keys/ssh_host_rsa_key"]
},
"ftpd": {
"bindings": [
{
"address": "0.0.0.0",
"port": {{ sftpgo_ftps_port }},
"apply_proxy_config": false,
"tls_mode": 2,
"force_passive_ip": "",
"passive_ip_overrides": [],
"passive_connections_security": 0
}
],
"passive_port_range": {
"start": {{ sftpgo_passive_port_min }},
"end": {{ sftpgo_passive_port_max }}
},
"certificate_file": "/var/lib/sftpgo/certs/fullchain.pem",
"certificate_key_file": "/var/lib/sftpgo/certs/privkey.pem"
},
"httpd": {
"bindings": [
{
"address": "127.0.0.1",
"port": {{ sftpgo_admin_port }},
"enable_web_admin": true,
"enable_web_client": false,
"enable_rest_api": true
}
]
},
"data_provider": {
"driver": "postgresql",
"name": "{{ sftpgo_pg_database }}",
"host": "{{ sftpgo_pg_host }}",
"port": {{ sftpgo_pg_port }},
"username": "{{ sftpgo_pg_username }}",
"password": "{{ sftpgo_pg_password }}",
"sslmode": 0,
"track_quota": 2
},
"common": {
"idle_timeout": 15,
"upload_mode": 0,
"actions": {
"execute_on": ["upload"],
"execute_sync": []
}
},
"filesystems": [
{
"fs_type": "s3",
"name": "minio_default",
"params": {
"bucket": "{{ sftpgo_minio_bucket }}",
"endpoint": "{{ sftpgo_minio_endpoint }}",
"region": "us-east-1",
"access_key": "{{ sftpgo_minio_access_key }}",
"access_secret": {
"status": "Plain",
"payload": "{{ sftpgo_minio_secret_key }}"
},
"force_path_style": true
}
}
]
}

View file

@ -0,0 +1,29 @@
---
# SHKeeper — self-hosted crypto payment processor
# Deployed via Helm chart onto k3s (lightweight Kubernetes)
shkeeper_port: 5000
shkeeper_domain: pay.performancewest.net
shkeeper_admin_domain: crypto.performancewest.net
# Cryptocurrencies to enable
shkeeper_cryptos:
btc: true
eth: true
matic: true
trx: true
bnb: true
ltc: true
# k3s options
k3s_install_url: https://get.k3s.io
k3s_exec_args: "--docker --disable=traefik"
# Helm repos
shkeeper_helm_repo: https://vsys-host.github.io/helm-charts
shkeeper_secret_gen_repo: https://helm.mittwald.de
# Helm release
shkeeper_helm_release: shkeeper
shkeeper_helm_namespace: default
shkeeper_storage_class: local-path

View file

@ -0,0 +1,163 @@
---
# Performance West — SHKeeper role
# Installs k3s (lightweight Kubernetes), Helm, and deploys SHKeeper
# via the official Helm chart for crypto payment processing.
#
# SHKeeper supports: BTC, ETH, USDC/USDT (ERC-20), Polygon, Tron, BNB, LTC, DOGE, XMR, SOL, AVAX, XRP
# All free, open-source, non-custodial.
#
# Architecture:
# k3s manages SHKeeper pods (crypto daemons + Flask API + MariaDB)
# nginx on the host proxies pay.performancewest.net → k3s NodePort/LoadBalancer
# ERPNext frappe_crypto app calls SHKeeper REST API to create invoices
# SHKeeper webhooks POST back to ERPNext on payment confirmation
# ── k3s installation ──────────────────────────────────────────────────────────
- name: Check if k3s is already installed
ansible.builtin.stat:
path: /usr/local/bin/k3s
register: k3s_binary
- name: Install k3s with Docker runtime
ansible.builtin.shell: |
curl -sfL {{ k3s_install_url }} | INSTALL_K3S_EXEC="{{ k3s_exec_args }}" sh -
args:
creates: /usr/local/bin/k3s
when: not k3s_binary.stat.exists
- name: Wait for k3s to be ready
ansible.builtin.command: k3s kubectl get nodes
register: k3s_nodes
retries: 12
delay: 5
until: "'Ready' in k3s_nodes.stdout"
changed_when: false
- name: Ensure deploy user has kubectl access
block:
- name: Create .kube directory for deploy user
ansible.builtin.file:
path: "/home/{{ deploy_user }}/.kube"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0700"
- name: Copy k3s kubeconfig for deploy user
ansible.builtin.copy:
src: /etc/rancher/k3s/k3s.yaml
dest: "/home/{{ deploy_user }}/.kube/config"
remote_src: true
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0600"
# ── Helm installation ─────────────────────────────────────────────────────────
- name: Check if helm is already installed
ansible.builtin.stat:
path: /usr/local/bin/helm
register: helm_binary
- name: Install Helm 3
ansible.builtin.shell: |
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
args:
creates: /usr/local/bin/helm
when: not helm_binary.stat.exists
# ── Helm repos ────────────────────────────────────────────────────────────────
- name: Add SHKeeper Helm repo
kubernetes.core.helm_repository:
name: vsys-host
repo_url: "{{ shkeeper_helm_repo }}"
become: true
become_user: "{{ deploy_user }}"
environment:
KUBECONFIG: "/home/{{ deploy_user }}/.kube/config"
- name: Add mittwald Helm repo (kubernetes-secret-generator)
kubernetes.core.helm_repository:
name: mittwald
repo_url: "{{ shkeeper_secret_gen_repo }}"
become: true
become_user: "{{ deploy_user }}"
environment:
KUBECONFIG: "/home/{{ deploy_user }}/.kube/config"
# ── Deploy kubernetes-secret-generator (required by SHKeeper) ─────────────────
- name: Install kubernetes-secret-generator
kubernetes.core.helm:
name: kubernetes-secret-generator
chart_ref: mittwald/kubernetes-secret-generator
release_namespace: default
state: present
update_repo_cache: true
become: true
become_user: "{{ deploy_user }}"
environment:
KUBECONFIG: "/home/{{ deploy_user }}/.kube/config"
# ── Deploy SHKeeper values.yaml ──────────────────────────────────────────────
- name: Deploy SHKeeper Helm values.yaml
ansible.builtin.template:
src: shkeeper-values.yaml.j2
dest: "{{ project_dir }}/shkeeper-values.yaml"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0644"
# ── Deploy SHKeeper ──────────────────────────────────────────────────────────
- name: Install/upgrade SHKeeper via Helm
kubernetes.core.helm:
name: "{{ shkeeper_helm_release }}"
chart_ref: vsys-host/shkeeper
release_namespace: "{{ shkeeper_helm_namespace }}"
state: present
values_files:
- "{{ project_dir }}/shkeeper-values.yaml"
update_repo_cache: true
become: true
become_user: "{{ deploy_user }}"
environment:
KUBECONFIG: "/home/{{ deploy_user }}/.kube/config"
- name: Wait for SHKeeper pod to be ready
ansible.builtin.command: >
k3s kubectl -n shkeeper wait --for=condition=ready pod
-l app=shkeeper --timeout=300s
register: shkeeper_ready
retries: 3
delay: 10
until: shkeeper_ready.rc == 0
changed_when: false
ignore_errors: true
# ── UFW: allow k3s NodePort range ────────────────────────────────────────────
- name: Allow SHKeeper NodePort access from localhost only
community.general.ufw:
rule: allow
port: "{{ shkeeper_port }}"
proto: tcp
from_ip: 127.0.0.1
comment: "SHKeeper web UI (k3s NodePort, localhost only)"
# ── Remove stale Bitcart Docker containers if present ────────────────────────
- name: Stop and remove Bitcart Docker containers (replaced by SHKeeper)
community.docker.docker_container:
name: "{{ item }}"
state: absent
loop:
- performancewest-bitcart-1
- performancewest-bitcart-worker-1
- performancewest-bitcart-admin-1
- performancewest-bitcart-postgres-1
- performancewest-bitcart-redis-1
ignore_errors: true

View file

@ -0,0 +1,74 @@
{{ ansible_managed | comment }}
#
# SHKeeper Helm Chart Values — Performance West
# Deployed at: {{ shkeeper_domain }}
# Admin panel: {{ shkeeper_admin_domain }}
#
storageClassName: {{ shkeeper_storage_class }}
{% if shkeeper_cryptos.btc | default(false) %}
#
# Bitcoin (BTC) — Electrum-based daemon
#
btc:
enabled: true
{% endif %}
{% if shkeeper_cryptos.eth | default(false) %}
#
# Ethereum (ETH) + ERC-20 tokens (USDC, USDT, DAI)
#
eth:
enabled: true
{% endif %}
{% if shkeeper_cryptos.matic | default(false) %}
#
# Polygon (MATIC) + ERC-20 tokens on Polygon
#
matic:
enabled: true
{% endif %}
{% if shkeeper_cryptos.trx | default(false) %}
#
# Tron (TRX) + TRC-20 tokens (USDT, USDC)
#
trx:
enabled: true
{% endif %}
{% if shkeeper_cryptos.bnb | default(false) %}
#
# BNB Smart Chain + BEP-20 tokens
#
bnb:
enabled: true
{% endif %}
{% if shkeeper_cryptos.ltc | default(false) %}
#
# Litecoin (LTC)
#
ltc:
enabled: true
{% endif %}
{% if shkeeper_cryptos.doge | default(false) %}
#
# Dogecoin (DOGE)
#
doge:
enabled: true
{% endif %}
{% if shkeeper_cryptos.xmr | default(false) %}
#
# Monero (XMR) — requires full node
#
monero:
enabled: true
fullnode:
enabled: true
{% endif %}

View file

@ -0,0 +1,2 @@
---
site_port: 4322

View file

@ -0,0 +1,43 @@
---
- name: Create site deployment directory
ansible.builtin.file:
path: /opt/performancewest/site
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0755"
- name: Sync site source code
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../../site/"
dest: /opt/performancewest/site/
delete: true
rsync_opts:
- "--exclude=node_modules"
- "--exclude=dist"
- "--exclude=.astro"
register: site_sync
- name: Build site container
ansible.builtin.command:
cmd: docker compose build site
chdir: /opt/performancewest
register: site_build
changed_when: "'writing image' in site_build.stderr"
- name: Start site container
ansible.builtin.command:
cmd: docker compose up -d site
chdir: /opt/performancewest
register: site_start
changed_when: "'Started' in site_start.stderr or 'Creating' in site_start.stderr"
- name: Wait for site to be reachable
ansible.builtin.uri:
url: "http://127.0.0.1:{{ site_port }}/"
status_code: 200
timeout: 5
register: site_health
retries: 12
delay: 5
until: site_health.status == 200

View file

@ -0,0 +1,159 @@
---
# worker-crons defaults
#
# Each entry in `worker_crons` deploys a systemd .service + .timer pair.
# Times use systemd OnCalendar syntax (see `man systemd.time`).
#
# The service runs inside the workers container via
# docker compose exec -T workers python -m <module>
#
# Why systemd timers not crond: timers log to journald (queryable with
# `journalctl -u <name>.service`), support OnBootSec/Persistent for catch-up
# after a downed host, and inherit the docker daemon restart policy
# without additional tooling.
project_dir: /opt/performancewest
worker_crons:
# USF quarterly factor — polls USAC daily at 09:00 Chicago local.
- name: pw-usf-factor-monitor
description: Poll USAC for new USF quarterly contribution factor
module: scripts.workers.usf_factor_monitor
on_calendar: "*-*-* 14:00:00 UTC" # 09:00 CT
persistent: true
# De minimis factor presence — 3am daily, alerts if missing.
- name: pw-deminimis-factor-check
description: Alert if fcc_deminimis_factors missing for current/next year
module: scripts.workers.deminimis_factor_check
on_calendar: "*-*-* 03:00:00 UTC"
persistent: true
# Cold wallet sweep — every 30 min.
- name: pw-cold-wallet-sweep
description: Sweep SHKeeper hot-wallet excess to cold wallet
module: scripts.workers.cold_wallet_sweeper
on_calendar: "*-*-* *:00,30:00 UTC"
persistent: false
# Crypto payment worker — every 60s while we have pending jobs.
- name: pw-crypto-payment-worker
description: Advance crypto_payment_jobs state machine
module: scripts.workers.crypto_payment_worker
on_calendar: "*-*-* *:*:00 UTC"
persistent: false
# Relay deposit monitor — every 5 min, polls IMAP for Relay alerts.
- name: pw-relay-deposit-monitor
description: Parse Relay email alerts into relay_deposits
module: scripts.workers.relay_deposit_monitor
on_calendar: "*-*-* *:00/5:00 UTC"
persistent: false
# Commission worker — 02:00 daily (flips 14-day-eligible rows to eligible).
- name: pw-commission-worker
description: Flip commission_ledger rows to eligible after 14-day holdback
module: scripts.workers.commission_worker
on_calendar: "*-*-* 02:00:00 UTC"
persistent: true
# Renewal worker — 04:00 daily.
- name: pw-renewal-worker
description: Send renewal reminders + auto-renew RA/annual report/CDR
module: scripts.workers.renewal_worker
on_calendar: "*-*-* 04:00:00 UTC"
persistent: true
# CDR retention sweeper — 05:00 daily (purges past-retention CDR).
- name: pw-cdr-retention
description: Purge CDR older than the per-profile retention window
module: scripts.workers.cdr_retention_sweeper
on_calendar: "*-*-* 05:00:00 UTC"
persistent: true
# CDR unlock nudge — 10:00 daily (emails customers with locked studies).
- name: pw-cdr-unlock-nudge
description: Nudge customers whose CDR study is locked behind paywall
module: scripts.workers.cdr_unlock_nudge
on_calendar: "*-*-* 15:00:00 UTC" # 10:00 CT
persistent: true
# Payment reminder — 11:00 daily.
- name: pw-payment-reminder
description: Remind customers with unpaid compliance orders
module: scripts.workers.payment_reminder
on_calendar: "*-*-* 16:00:00 UTC" # 11:00 CT
persistent: true
# RMD removed scraper — weekly, Wednesday 08:00 (tracks FCC RMD removals).
- name: pw-fcc-rmd-removed
description: Scrape FCC public list of RMD-removed carriers
module: scripts.workers.fcc_rmd_removed_scraper
on_calendar: "Wed *-*-* 13:00:00 UTC"
persistent: true
# Client email processor — every 15 min, IMAPs regulatory mailboxes for
# CRTC pipeline events (incoming agency replies, domain activation, etc.).
- name: pw-client-email-processor
description: Parse regulatory mailbox replies for CRTC pipeline advancement
module: scripts.workers.client_email_processor
on_calendar: "*-*-* *:00/15:00 UTC"
persistent: false
# AMB location scraper — daily 06:00 UTC, pulls current per-mailbox pricing
# and sold-out status for the CRTC AMB picker.
- name: pw-amb-location-scraper
description: Scrape current Anytime Mailbox location + pricing catalog
module: scripts.workers.amb_location_scraper
on_calendar: "*-*-* 06:00:00 UTC"
persistent: true
# Compliance alert mailing list — weekly Sunday 06:00 UTC.
# Queries local DB for carriers behind on filings and upserts to Listmonk.
- name: pw-compliance-alert-list
description: Update Listmonk compliance alert mailing list from local FCC data
module: scripts.workers.compliance_alert_list
on_calendar: "Sun *-*-* 06:00:00 UTC"
persistent: true
# Entity cache refresh — daily 07:00 UTC (2am CT).
# Downloads business entity data from state Socrata APIs (CO, IA, CT, OR, NY)
# into entity_cache for corporation status checks.
- name: pw-entity-cache-refresh
description: Refresh entity_cache from state SOS open data portals
module: scripts.formation.bulk_download --all
on_calendar: "*-*-* 07:00:00 UTC"
persistent: true
# Multi-state entity scraper — daily 08:00 UTC (3am CT).
# Scrapes state SOS portals via Playwright for states without bulk APIs.
# Covers 44 states (excludes CO, IA, CT, OR, NY, FL which use bulk data).
- name: pw-entity-scraper
description: Scrape business entities from state SOS portals via Playwright
module: scripts.workers.entity_scraper --all
on_calendar: "*-*-* 08:00:00 UTC"
persistent: true
# Florida entity downloader — daily 07:30 UTC.
# Downloads daily diffs from FL Sunbiz SFTP (free public access).
- name: pw-fl-entity-downloader
description: Download Florida Sunbiz corporation data via SFTP
module: scripts.workers.fl_entity_downloader --daily
on_calendar: "*-*-* 07:30:00 UTC"
persistent: true
# RMD email scraper — daily 09:00 UTC (4am CT).
# Scrapes contact emails from FCC ServiceNow SP API for RMD carriers.
- name: pw-rmd-email-scraper
description: Scrape RMD contact emails from FCC ServiceNow portal
module: scripts.workers.fcc_rmd_scraper --phase scrape --limit 500
on_calendar: "*-*-* 09:00:00 UTC"
persistent: true
# RMD filing auditor — weekly Saturday 10:00 UTC (5am CT).
# Audits RMD filings for 2026 compliance deficiencies (PDF analysis).
- name: pw-rmd-auditor
description: Audit RMD filings for 2026 compliance deficiencies
module: scripts.workers.fcc_rmd_auditor --batch --year 2026 --no-ollama
on_calendar: "Sat *-*-* 10:00:00 UTC"
persistent: true

View file

@ -0,0 +1,4 @@
---
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true

View file

@ -0,0 +1,43 @@
---
# worker-crons — deploy systemd .service + .timer pairs for every
# scheduled worker job. Each entry in `worker_crons` (defaults/main.yml)
# expands into /etc/systemd/system/{name}.{service,timer}.
- name: Deploy worker cron .service units
ansible.builtin.template:
src: cron.service.j2
dest: "/etc/systemd/system/{{ cron.name }}.service"
owner: root
group: root
mode: "0644"
loop: "{{ worker_crons }}"
loop_control:
loop_var: cron
notify: reload systemd
- name: Deploy worker cron .timer units
ansible.builtin.template:
src: cron.timer.j2
dest: "/etc/systemd/system/{{ cron.name }}.timer"
owner: root
group: root
mode: "0644"
loop: "{{ worker_crons }}"
loop_control:
loop_var: cron
notify: reload systemd
# Force the daemon-reload BEFORE enabling, or `systemctl enable` won't
# see the new .timer units.
- name: Reload systemd now (so enables below see new units)
ansible.builtin.systemd:
daemon_reload: true
- name: Enable + start worker cron timers
ansible.builtin.systemd:
name: "{{ cron.name }}.timer"
enabled: true
state: started
loop: "{{ worker_crons }}"
loop_control:
loop_var: cron

View file

@ -0,0 +1,15 @@
[Unit]
Description={{ cron.description }}
After=performancewest.service
Requires=performancewest.service
[Service]
Type=oneshot
User=root
WorkingDirectory={{ project_dir }}
ExecStart=/usr/bin/docker compose exec -T workers python -m {{ cron.module }}
# Log to journald; journalctl -u {{ cron.name }}.service
StandardOutput=journal
StandardError=journal
# Prevent overlapping runs for every-minute jobs
TimeoutStartSec=300

View file

@ -0,0 +1,11 @@
[Unit]
Description={{ cron.description }} (timer)
[Timer]
OnCalendar={{ cron.on_calendar }}
Persistent={{ cron.persistent | bool | ternary('true', 'false') }}
RandomizedDelaySec=10
Unit={{ cron.name }}.service
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,2 @@
---
ollama_model: "{{ vault_ollama_model | default('qwen2.5:7b') }}"

View file

@ -0,0 +1,42 @@
---
# Performance West — Python workers role
# Starts the document generation + formation automation workers container.
- name: Ensure workers container is running
community.docker.docker_compose_v2:
project_src: "{{ project_dir }}"
services:
- workers
- ollama
state: present
register: workers_compose
- name: Pull Ollama model (first run only)
ansible.builtin.command:
cmd: >-
docker compose exec -T ollama
ollama pull {{ ollama_model }}
chdir: "{{ project_dir }}"
args:
creates: "{{ project_dir }}/ollama-model-pulled"
register: ollama_pull
changed_when: ollama_pull.rc == 0
- name: Mark Ollama model as pulled
ansible.builtin.file:
path: "{{ project_dir }}/ollama-model-pulled"
state: touch
when: ollama_pull.changed
- name: Verify workers job server is accepting connections
ansible.builtin.command:
cmd: >-
docker compose exec -T workers
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8090/health')"
chdir: "{{ project_dir }}"
register: workers_health
retries: 10
delay: 5
until: workers_health.rc == 0
changed_when: false
failed_when: false

View file

@ -0,0 +1,11 @@
# fail2ban filter for Performance West API
# Parses structured JSON access logs from the Express API.
# Matches 403 (forbidden), 429 (rate limited), and 415 (wrong content type) responses.
[Definition]
# Match rate-limited or forbidden requests from API structured logs
failregex = ^\[ACCESS\] .*"ip":\s*"<HOST>".*"status":\s*(403|429|415)
^.*\[ACCESS\].*"ip":\s*"<HOST>".*"status":\s*(403|429|415)
ignoreregex =

View file

@ -0,0 +1,10 @@
# fail2ban jail for Performance West API abuse protection
[pw-api]
enabled = true
filter = pw-api
logpath = /var/log/pw-api.log
maxretry = 20
findtime = 300
bantime = 3600
action = iptables-multiport[name=pw-api, port="80,443", protocol=tcp]

View file

@ -0,0 +1,38 @@
# Listmonk — lists.performancewest.net
server {
listen 443 ssl;
http2 on;
server_name lists.performancewest.net;
ssl_certificate /etc/letsencrypt/live/lists.performancewest.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/lists.performancewest.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 50m;
# Listmonk admin UI + API
location / {
proxy_pass http://127.0.0.1:9100;
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;
proxy_http_version 1.1;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
server {
listen 80;
server_name lists.performancewest.net;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}