Spectrum Deployment Guide
Deployment guide · v1.0 · September 2026

Spectrum on AWS

A complete record of the live deployment in the manju2bspectrum account: every resource created, every incident hit and how it was fixed, and the exact runbook for redeploying, rotating secrets, and restoring from backup.

01What's live, right now

One paragraph, then the receipts.

Spectrum's cockpit, orders tab, strategy tab, architecture tab, and this deployment guide are all served over HTTPS at https://spectrummomentum.com, from one small EC2 instance in us-east-1, behind Caddy for automatic TLS. The app, its systemd timers (weekly momentum rebuild, twice-daily cockpit refresh, nightly backup), and its history database all run on that one box. Nothing here places real orders; nothing here is investment advice.

CheckResult
DNS resolvesspectrummomentum.com → 100.57.141.223 ✓
TLS certificatevalid, auto-issued by Caddy (Let's Encrypt) ✓
GET /200 · server: uvicorn · via: 1.1 Caddy
GET /orders /strategy /architecture /whitepaper200 on all four ✓
GET /api/ops, /api/momentum200, serving from the database ✓
Verified fromcurl on the EC2 instance itself — deliberately not from this laptop's browser, which sits behind corporate (Zscaler) browser isolation that intercepts new domains and would give a false reading either way

02Infrastructure diagram

Visitor's browser anywhere on the internet Route 53 spectrummomentum.com → Elastic IP DNS lookup HTTPS :443 EC2 instance i-0bc7afe752c9ef8b5 (t3.small, Ubuntu 22.04) · Elastic IP 100.57.141.223 · us-east-1 Caddy (snap) terminates TLS, auto-renews cert listens :80 and :443 proxy spectrum.service uvicorn/FastAPI on 127.0.0.1:8060 systemd, Restart=on-failure /opt/spectrum/data spectrum.db · momentum.json · ops.json systemd timers spectrum-momentum.timer Sat 06:00 UTC — rebuild book spectrum-ops.timer Mon–Fri 13:00 & 21:00 UTC spectrum-backup.timer daily 05:00 UTC → S3 caddy-config.service reloads Caddyfile on (re)start IAM instance role SSM Parameter Store /spectrum/POLYGON_API_KEY (SecureString) /spectrum/ADANOS_API_KEY (SecureString) S3 — spectrum-deploy-814203451977 releases/ (code) · backups/ (nightly, versioned) private, public access blocked AWS Systems Manager Session Manager — the ONLY shell access no SSH port open anywhere; administered via `aws ssm start-session` / `send-command` Security group 80, 443 open · nothing else Domain registered + hosted zone auto-created in the same account (Route 53)
Figure 1. Solid boxes are inside the instance; dashed-border boxes are public-internet facing. The instance has no inbound SSH — all administration is through SSM Session Manager, which needs only outbound HTTPS from the instance (already required for the app itself) and an IAM role, not an open port.

03Resource inventory

Every real identifier, so future-you never has to hunt for it in the console.

ResourceValue
AWS account814203451977 (manju2bspectrum)
Regionus-east-1
IAM deploy userspectrum-deploy (AdministratorAccess — see §4)
Local CLI profilespectrum in ~/.aws/credentials on this Mac
VPCvpc-061aa2c370c45b23e (default VPC)
Subnetsubnet-09dd0206dca86a9af
Security groupsg-0fb5b37484bcdd1b0 (spectrum-web-sg: 80, 443 only)
AMIami-05a3e9423ae4d7a19 (Ubuntu 22.04 LTS, amd64, via Canonical's public SSM parameter)
EC2 instancei-0bc7afe752c9ef8b5 (t3.small, 15 GB gp3)
Elastic IP100.57.141.223 (alloc eipalloc-09b3d6c6edd443036)
IAM instance rolespectrum-ec2-role + instance profile spectrum-ec2-profile
S3 bucketspectrum-deploy-814203451977 (private, versioned)
SSM parameters/spectrum/POLYGON_API_KEY, /spectrum/ADANOS_API_KEY (SecureString)
Domainspectrummomentum.com, registered via Route 53, auto-renew ON
Hosted zoneZ05618432FZYFBPA2TK4Y
App directory on instance/opt/spectrum, owned by system user spectrum

04Security model

Known compromise, by choice: the spectrum-deploy IAM user (used from this laptop to provision everything) has AdministratorAccess — appropriate for a single-person account during initial build-out, but broader than it needs to be long-term. Tighten it to a scoped policy (EC2, S3, SSM, Route 53, IAM-role-passing only) once the resource list stops changing, and never reuse this key for anything outside this project.

05Cost — real numbers

ItemRateMonthly
EC2 t3.small (on-demand, us-east-1)$0.0208/hr≈ $15.20
EBS gp3, 15 GB$0.08/GB-mo≈ $1.20
Elastic IP (attached to a running instance)free while attached$0.00
Route 53 hosted zone$0.50/mo$0.50
Route 53 DNS queries~free at this volume≈ $0.00
S3 (code + backups, <1 GB)$0.023/GB-mo≈ $0.05
SSM Parameter Store (Standard tier)free$0.00
Data transfer out (light personal use)first 100GB/mo free≈ $0.00
Recurring monthly total≈ $17/mo
Domain registration (spectrummomentum.com)$16/yr, auto-renew≈ $1.33/mo amortized
All-in≈ $18/mo

Biggest lever if this needs to be cheaper: stop the instance when not in use (EBS still bills, EC2 compute doesn't — but the site goes down while stopped, so this only suits a dev/demo pattern, not a daily-use cockpit), or move to a t3.micro / t4g.small; the app's own footprint is tiny (a few hundred MB RAM), t3.small has ample headroom to spare.

06Incidents hit during this deploy, and the fixes

Recorded honestly — this is exactly what to check first if a future redeploy breaks the same way.

Incident 1 — secrets printed in plaintext by set -x. The first bootstrap script used set -euxo pipefail for the whole file, which traces every command including POLYGON_KEY=$(...) — this echoed both API keys into /var/log/spectrum-finish.log on the instance and into AWS's own SSM command-history API response (visible to anyone with access to this AWS account, not the public internet).
Fix: the log file was shredded (shred -u) immediately. Any future bootstrap script must wrap secret-handling lines in set +x ... set -x, or better, pipe secret retrieval through a subshell that never gets traced. Action item for you: rotate the Polygon and Adanos API keys when convenient (read-only market-data keys, not brokerage credentials — low severity, but rotate on general hygiene). Tell me when you have new ones and I'll update the two SSM parameters in one command.
Incident 2 — aws: command not found mid-bootstrap. The stock Ubuntu 22.04 AMI does not ship the AWS CLI, so the very first aws s3 cp in the user-data script failed, and set -e killed everything after it — the app was never actually installed on the first attempt.
Fix: installed AWS CLI v2 via the official curl+unzip installer as a separate step before anything that depends on it. Baked into the launch template for any future instance: install awscli v2 as step one of user-data, before referencing it anywhere.
Incident 3 — spectrum.service failed with exit code 2 (INVALIDARGUMENT). The systemd unit ran spectrum --host 127.0.0.1 --port 8060 serve — but in this CLI, --port is a top-level flag and --host belongs to the serve subcommand, so --host appearing before the subcommand token was rejected by argparse.
Fix: corrected to spectrum --port 8060 serve --host 127.0.0.1. Worth fixing in the CLI itself eventually (move --port under serve only) so this class of mistake becomes impossible.
Incident 4 — Caddy "active" but nothing listening on 80/443. The Caddy snap starts with an empty default config and does not automatically read /var/snap/caddy/common/Caddyfile on its own — it must be pushed via caddy reload against the local admin API every time the service starts. This meant the first restart silently reverted to serving nothing.
Fix: added caddy-config.service — a companion systemd unit with After=/Requires=/PartOf=snap.caddy.server.service that runs caddy reload --config ... --adapter caddyfile (with a short retry loop) every time the Caddy service starts or is restarted. Verified by force-restarting Caddy and confirming the config re-loaded automatically with no manual step.
Incident 5 — this laptop's own network gave a false negative. Verifying https://spectrummomentum.com from the Claude Code browser on this Mac showed a Zscaler "Browser Isolation" interstitial and an isolation-client HTML shell instead of the real page — corporate policy intercepting a brand-new, not-yet-categorized domain.
Fix: verified instead by running curl from the EC2 instance itself via SSM (its outbound traffic doesn't transit this laptop's corporate proxy), which confirmed a genuine 200, valid TLS, server: uvicorn, via: 1.1 Caddy, and the correct page title and JSON payload. Note for you: if you check the site from a work device/network and see an "isolation" page, that's Zscaler, not a deployment problem — try from your phone on cellular data or a personal connection instead.

07Runbook: access the server

No SSH key exists for this instance on purpose. Get a shell with:

export PATH="$HOME/Library/Python/3.9/bin:$PATH"   # this session's aws CLI location
aws ssm start-session --target i-0bc7afe752c9ef8b5 --profile spectrum --region us-east-1

For a single command without a full session (what this deployment used throughout):

aws ssm send-command --instance-ids i-0bc7afe752c9ef8b5 \
  --document-name "AWS-RunShellScript" \
  --parameters 'commands=["systemctl status spectrum.service --no-pager"]' \
  --profile spectrum --region us-east-1 --query 'Command.CommandId' --output text
# then, with the returned id:
aws ssm get-command-invocation --command-id <ID> --instance-id i-0bc7afe752c9ef8b5 \
  --profile spectrum --region us-east-1 --query '[Status,StandardOutputContent,StandardErrorContent]' --output text

Useful checks once connected

systemctl status spectrum.service --no-pager        # is the app up?
journalctl -u spectrum.service -n 50 --no-pager      # app logs
systemctl status snap.caddy.server.service           # is Caddy up?
journalctl -u caddy-config.service -n 20 --no-pager   # did the Caddyfile load?
systemctl list-timers --no-pager | grep spectrum      # when do the jobs next run?

08Runbook: redeploy new code

Whenever the Spectrum codebase changes on this Mac, ship it with:

# 1. package (from the repo root, excludes .venv and the local bar cache)
tar czf /tmp/spectrum-app.tar.gz --exclude='.venv' --exclude='data/bars' \
  --exclude='*.pyc' --exclude='__pycache__' --exclude='.env' spectrum pyproject.toml

# 2. upload
aws s3 cp /tmp/spectrum-app.tar.gz s3://spectrum-deploy-814203451977/releases/spectrum-app.tar.gz \
  --profile spectrum --region us-east-1

# 3. pull + reinstall on the instance
aws ssm send-command --instance-ids i-0bc7afe752c9ef8b5 --document-name "AWS-RunShellScript" \
  --parameters 'commands=[
    "aws s3 cp s3://spectrum-deploy-814203451977/releases/spectrum-app.tar.gz /tmp/a.tar.gz --region us-east-1",
    "tar xzf /tmp/a.tar.gz -C /opt/spectrum",
    "chown -R spectrum:spectrum /opt/spectrum",
    "sudo -u spectrum /opt/spectrum/.venv/bin/pip install --quiet -e /opt/spectrum",
    "systemctl restart spectrum.service"
  ]' --profile spectrum --region us-east-1

If a web page (HTML/CSS/JS) changed but no Python did, the same steps still apply — the tarball includes spectrum/web/. FastAPI serves those files fresh on every request, so once the tarball lands there's no extra caching step to bust.

09Runbook: rotate secrets

Get a new key from Polygon.io or Adanos, then, without ever pasting it into a command that gets logged with tracing on:

aws ssm put-parameter --name /spectrum/POLYGON_API_KEY --value "NEW_KEY_HERE" \
  --type SecureString --overwrite --profile spectrum --region us-east-1

aws ssm send-command --instance-ids i-0bc7afe752c9ef8b5 --document-name "AWS-RunShellScript" \
  --parameters 'commands=[
    "P=$(aws ssm get-parameter --name /spectrum/POLYGON_API_KEY --with-decryption --region us-east-1 --query Parameter.Value --output text)",
    "A=$(aws ssm get-parameter --name /spectrum/ADANOS_API_KEY --with-decryption --region us-east-1 --query Parameter.Value --output text)",
    "printf \"POLYGON_API_KEY=%s\\nADANOS_API_KEY=%s\\n\" \"$P\" \"$A\" > /opt/spectrum/.env",
    "chown spectrum:spectrum /opt/spectrum/.env && chmod 600 /opt/spectrum/.env",
    "systemctl restart spectrum.service"
  ]' --profile spectrum --region us-east-1

Note this command block intentionally avoids set -x and never echoes the variables — the lesson from Incident 1.

10Runbook: backup & disaster recovery

spectrum-backup.timer runs nightly at 05:00 UTC and copies spectrum.db, momentum.json and ops.json to s3://spectrum-deploy-814203451977/backups/. The bucket has versioning enabled, so every night's backup is a separate recoverable version even though the object key never changes.

To restore onto a fresh instance

aws s3 cp s3://spectrum-deploy-814203451977/backups/spectrum.db /opt/spectrum/data/spectrum.db
aws s3 cp s3://spectrum-deploy-814203451977/backups/momentum.json /opt/spectrum/data/momentum.json
aws s3 cp s3://spectrum-deploy-814203451977/backups/ops.json /opt/spectrum/data/ops.json
chown spectrum:spectrum /opt/spectrum/data/*
systemctl restart spectrum.service

To recover an older version (versioned bucket)

aws s3api list-object-versions --bucket spectrum-deploy-814203451977 --prefix backups/spectrum.db \
  --profile spectrum --region us-east-1
# then restore a specific VersionId with:
aws s3api get-object --bucket spectrum-deploy-814203451977 --key backups/spectrum.db \
  --version-id <VERSION_ID> /tmp/spectrum-old.db --profile spectrum --region us-east-1

The 10-year price-bar cache (data/bars/*.parquet) is not backed up — it's disposable and rebuilds automatically from Polygon on the next spectrum momentum run if ever lost, at the cost of one API pass over ~133 symbols.

11Domain & DNS reference

Value
RegistrarRoute 53 Domains, same AWS account
Auto-renewON — renews automatically each year at the then-current Route 53 price (~$16 today)
WHOIS privacyON for registrant, admin and tech contacts
Hosted zoneZ05618432FZYFBPA2TK4Y
A recordsspectrummomentum.com and www.spectrummomentum.com100.57.141.223, TTL 300s
TLSissued and auto-renewed by Caddy (Let's Encrypt), no manual cert management ever needed

If the Elastic IP ever changes

(It won't unless the EIP is explicitly released — but if it ever does:)

aws route53 change-resource-record-sets --hosted-zone-id Z05618432FZYFBPA2TK4Y \
  --change-batch '{"Changes":[
    {"Action":"UPSERT","ResourceRecordSet":{"Name":"spectrummomentum.com","Type":"A","TTL":300,
      "ResourceRecords":[{"Value":"NEW.IP.HERE"}]}},
    {"Action":"UPSERT","ResourceRecordSet":{"Name":"www.spectrummomentum.com","Type":"A","TTL":300,
      "ResourceRecords":[{"Value":"NEW.IP.HERE"}]}}]}' \
  --profile spectrum --region us-east-1

12What's NOT done yet