Skip to content

SFTPGo (heatwave-sftp) — the PBX-facing SFTP gateway, R2-backed

The single SFTP endpoint the Switchvox PBX (144.202.57.170) talks to. It runs as a Kamal accessory on the Dallas bare-metal host and stores everything it receives directly in Cloudflare R2 — there is no host-local recordings/backup mount, so the data survives any app/DB host failover.

Image: drakkan/sftpgo:v2.6.6 (open-source edition). Accessory definition: config/deploy.ymlaccessories.sftp. User provisioning: config/sftpgo/bootstrap_call_recordings.sh (also see config/sftpgo/README.md for the operator runbook).

CapabilitySFTPGo userLands inConsumer
1. Call-recording ingestionpbxR2 heatwave-call-recordings-production, prefix WarmlyYours/CallRecordImporterWorker reads R2 → transcription/analysis
2. PBX system backupspbx-backupR2 heatwave-pbx-backups-production (ENAM), bucket rootDR archive (Switchvox rotates by retention count)
3. WebAdmin / WebClient UItailnet :8080Humans: manage users, browse the R2 buckets, watch live connections
4. SMTP notifierSendGrid relaySFTPGo email (password resets + Event Manager notifications)

Each Switchvox feature points at a distinct SFTPGo user, and each user maps to its own R2 bucket — so a leaked credential for one can’t touch the other, and backup retention/lifecycle is independent of recordings.

Switchvox PBX (144.202.57.170) Cloudflare R2 (account 79b7f58…)
│ ├─ heatwave-call-recordings-production/WarmlyYours/
│ SFTP (Use SFTP = YES) └─ heatwave-pbx-backups-production/ (ENAM, off-Latitude)
▼ ▲ ▲
67.213.118.15 : 2222 ──DOCKER-USER firewall──► heatwave-sftp accessory │ (pbx user, s3 home)
(public; locked to the PBX IP) dal-latitude-heatwave-01 │
container :2022 (SFTP) │ (pbx-backup user, s3 home)
100.123.47.52 : 8080 ─────tailnet only────────► container :8080 (WebAdmin/WebClient UI)
(no public exposure)
Rails: CallRecordImporterWorker ──reads──► heatwave-call-recordings-production/WarmlyYours/*.wav
PropertyValue
Hostdal-latitude-heatwave-01 (Dallas Latitude; also the PG18 primary box)
Public IP67.213.118.15 — SFTP only, port 2222
Tailnet IP100.123.47.52 — WebAdmin UI only, port 8080
SFTP porthost 2222 → container 2022 (SFTPGO_SFTPD__BINDINGS__0__PORT=2022)
Firewallhost-level DOCKER-USER rule allows 2222 only from 144.202.57.170 (the PBX). Scoped with --ctorigdstport 2222 in infra/terraform/latitude/cloud-init.yaml.tftpl. The PBX-side backup feature originates from the same IP, so no rule change was needed to add it.
R2 endpointhttps://79b7f58cf035093b5ad11747df30369a.r2.cloudflarestorage.com (force_path_style, region: auto)

The recordings server was historically plain FTP/SFTP writing to a host-local /data/callrecords mount (ProFTPD on a Vultr box, later an atmoz/sftp accessory). Both tied the pipeline to one host’s disk. Re-architected 2026-06-10 to SFTPGo with an S3/R2 filesystem backend: the PBX still speaks SFTP, but SFTPGo writes objects straight to R2, so the importer (and any future app/DB failover) depends only on R2, never on a shared host mount.

The Switchvox “create directory” false-positive

Section titled “The Switchvox “create directory” false-positive”

Switchvox verifies its configured FTP/SFTP path by trying to create it as a directory before each run. Against object storage (R2 has only virtual directories) that check false-fails — Could not create the directory (WarmlyYours) — on every cycle, even though the uploads themselves succeed. The fix, applied to every Switchvox feature: point its path at the login root / (which always exists) and let SFTPGo place the objects:

  • The pbx user’s s3config.key_prefix: "WarmlyYours/" maps its root onto the WarmlyYours/ prefix the importer already reads.
  • The pbx-backup user has no prefix, so backups land at its bucket root.

Gotcha: Switchvox caches the path in its verification step. After changing a path, the alarm keeps firing (still naming the old path) until the PBX itself is rebooted. The recordings call-recording offload also throws a separate, path-independent “backup directory verification” alarm that is a Switchvox-internal false-positive on object storage and is muted at the PBX (SV_CALL_RECORDING).

Provisioned at container start by bootstrap_call_recordings.sh, which writes a loaddata JSON and runs sftpgo serve --loaddata-from … --loaddata-mode 0 (mode 0 = add new / update existing; restores both users on every reboot).

UserBucketPrefixCredentials
pbxheatwave-call-recordings-productionWarmlyYours/container default chain (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY from op://IT/R2-call-recordings-production)
pbx-backupheatwave-pbx-backups-production— (root)explicit per-user s3config.access_key/access_secret — a dedicated bucket-scoped token (op://IT/R2-pbx-backups-production), not the recordings creds

The pbx-backup secret is given to SFTPGo as {"status":"Plain","payload":"…"}; SFTPGo’s builtin KMS encrypts it (AES-256-GCM) on load — no master key needed.

Both features live in the Switchvox admin. Use the same host:port for each; only the user/password/path differ. Use SFTP = YES (the server is SFTP-only on 2222 — it does not serve plain FTP).

Call recording offload (Setup → Call Recording):

FieldValue
Host / port67.213.118.15:2222
Usernamepbx
Passwordop://IT/R2-call-recordings-productionpbx_password
Path/

System backups (Setup → Set Up Automatic Backups):

FieldValue
Use SFTPYES
Host / port67.213.118.15:2222
Usernamepbx-backup
Passwordop://IT/R2-pbx-backups-productionpbx_backup_password
Path/
Number of backups to keepoperator’s choice — Switchvox rotates via SFTP DELETE (the user has * perms)

Published on the tailnet IP only (options.publish: 100.123.47.52:8080:8080) — never on the public interface. From any tailnet-connected machine:

  • Admin console: http://100.123.47.52:8080/web/admin — login admin / op://IT/SFTPGo (the dedicated item also holds the URL).
    • ⚠️ The bare :8080/ redirects to /web/client/login (the end-user portal, which only knows the SFTP users); logging in there as admin returns “user not found”. Always use /web/admin.
  • The admin password is seeded only at first data-provider init (CREATE_DEFAULT_ADMIN); rotating SFTPGO_DEFAULT_ADMIN_PASSWORD later does not update an already-created admin (reset via a loaddata admins block).

SFTPGo sends mail through the SendGrid SMTP relay (op://IT/SendGrid), so it can email password-reset codes and — via Event Manager rules — operational notifications.

SFTPGO_SMTP__*Value
HOST / PORTsmtp.sendgrid.net / 587
ENCRYPTION2 (STARTTLS)
AUTH_TYPE0 (PLAIN)
USERapikey (literal)
PASSWORDthe SendGrid send-only API key (op://IT/SendGrid → “Send-only API key…”), read by a direct op read in .kamal/secrets-common
FROMWarmlyYours SFTPGo <systems@warmlyyours.com>

The SMTP config is the transport only. To actually emit alerts, add an Event Manager rule (WebAdmin → Events, or a loaddata event_rules block). Note SFTPGo upload-event rules fire only when the PBX connects; “the backup silently stopped running” is better caught by an external daily freshness check.

ItemUsed for
R2-call-recordings-productionpbx SFTP password, recordings-bucket R2 token (AWS_*)
R2-pbx-backups-productionpbx-backup SFTP password + dedicated backups-bucket R2 token; minted/rotated by script/setup_r2_pbx_backups_token.sh
SFTPGoWebAdmin admin login + URL
SendGrid (systems@warmlyyours.com)SMTP relay send-only API key

All are resolved in .kamal/secrets-common and injected via the accessory’s env.secret (Kamal redacts them in logs).

Apply a config change (bootstrap, env, or secrets) — kamal deploy does not recreate accessories, so reboot it explicitly. The -d production host list includes an unreachable host, so always scope to the SFTP host:

Terminal window
mise exec -- bundle exec kamal accessory reboot sftp -d production --hosts 100.123.47.52

A reboot re-runs the bootstrap (restores both users from the loaddata JSON); expect restoring existing user: "pbx" / "pbx-backup" with error: <nil> and listeners on :2022 + :8080.

Add / change a user — edit the loaddata JSON in bootstrap_call_recordings.sh and reboot. Validate JSON shape in a throwaway container first (docker run --rm … drakkan/sftpgo:v2.6.6 -lc bootstrap…) before touching prod.

Rotate the backups R2 token — re-run script/setup_r2_pbx_backups_token.sh (preserves the existing SFTP password) and reboot.

Monitoring

  • Importer: CallRecordImporterWorker (hourly 6am–7pm CT) in the Sidekiq dashboard; AppSignal for SFTP/R2 errors.
  • Live SFTP sessions & file listings: the WebAdmin UI.
  • Backups landing: list heatwave-pbx-backups-production, or watch the pbx-backup user’s activity in the UI. First scheduled Switchvox run is nightly.

The recordings half of the pipeline (the Rails consumer) is documented in Call Recording System. In short: config.x.call_record_importer.storage = :r2 (prod/staging) routes CallRecordImporterWorkerCallRecordSwitchvoxImporterCloudStorageCallRecordSwitchvoxObjectStore, which reads *.wav under WarmlyYours/ using the call_recordings.object_store.* encrypted credentials (the bucket name must contain the environment string). Only .wav is imported; stray non-.wav objects are ignored.

By default the importer polls hourly. SFTPGo’s upload action hook makes the import event-driven: the instant the PBX finishes writing a recording, SFTPGo POSTs an FsActionNotification to the Rails app, which enqueues the import immediately — recordings appear in the CRM within seconds instead of waiting for the next poll.

PBX ──SFTP──▶ SFTPGo ──upload action hook (POST JSON)──▶
api.warmlyyours.com/webhooks/v1/sftpgo?token=…
└▶ Webhooks::V1::SftpgoController
└▶ WebhookLog.ingest!(provider: sftpgo, external_id: <wav R2 key>)
└▶ WebhookProcessorWorker → WebhookProcessors::SftpgoProcessor
└▶ CallRecordImporterWorker.perform_async(<wav R2 key>)

Design points:

  • Trigger on the .xml sidecar, not the .wav. Switchvox writes a paired <name>.wav + <name>.xml; keying off the metadata file guarantees the importer has both halves (a fractionally-late .wav is covered by the worker’s retry). The controller hard-filters to the pbx user’s .xml uploads and ACKs everything else (the .wav event, pbx-backup archives, archive moves).
  • The hourly poll stays as the backstop. Both paths call CallRecordImporterWorker.perform_async(<wav R2 key>) with the same key, so the worker’s :until_executed lock + the importer’s first_or_initialize + the post-import move to processed/ make a webhook+poll race a guaranteed no-op. Nothing is imported twice; nothing the hook drops is lost.
  • Auth is a shared-secret token query param (secure_compare), failing closed in production when unset.

Activation (one-time wiring — currently DORMANT until set)

Section titled “Activation (one-time wiring — currently DORMANT until set)”

The Rails endpoint ships ready; SFTPGo is not yet pointed at it. To turn it on:

  1. Mint a token: openssl rand -hex 24.

  2. Make the app trust it — set SFTPGO_WEBHOOK_TOKEN in the web role’s env.secret (via .kamal/secrets-common ← 1Password), or add sftpgo: { webhook_token: … } to Rails credentials. The controller reads ENV['SFTPGO_WEBHOOK_TOKEN'] first, then Heatwave::Configuration.fetch.

  3. Make SFTPGo call it — in the sftp accessory (config/deploy.yml):

    env:
    clear:
    SFTPGO_COMMON__ACTIONS__EXECUTE_ON: upload
    secret:
    - SFTPGO_COMMON__ACTIONS__HOOK # https://api.warmlyyours.com/webhooks/v1/sftpgo?token=<TOKEN>

    Store the full URL (token embedded) in 1Password → .kamal/secrets-common. The hook fires for every upload/user; the controller does the filtering, so no per-user SFTPGo config is needed.

  4. kamal deploy (reboots the web role + the sftp accessory).

This uses SFTPGo’s simple global Custom Actions hook (common.actions), configured entirely from env. SFTPGo also offers the newer rule-based Event Manager (per-user/path filters, configured via the data provider) — not needed here because the controller already does the filtering, and the env-only hook keeps all config in deploy.yml rather than in provider state. If we later want SFTPGo-side filtering, the Event Manager is the migration path.

A failed hook is logged by SFTPGo and never blocks the upload — the poll still imports it — so activation is low-risk and reversible (drop the two env keys).

  • Call Recording System — the Rails feature (import, transcription, UI).
  • Switchvox Webhooks — the other Switchvox integration.
  • config/sftpgo/README.md — operator quick-reference next to the bootstrap.