Two production paths are documented end-to-end here. Both assume
you've cloned the repo and composer install --no-dev + npm install + npm run build complete cleanly on the target machine.
- DreamHost VPS (Apache + cron) — the original prod target.
- Fly.io (single machine + scheduled cron) — Phase 10 alternative.
Pick one. The same .env.prod file works on both; the runtime
shape (Apache PHP module vs Fly machine PHP-FPM) is what differs.
Verification status (Phase 11 chunk 6, 2026-05-23): docs match what
make ci,make migrate, andmake ingest-*do on the respective targets (verified in-repo: fullmake ciruns green at 411 PHPUnit / 361 Vitest / 146 Playwright). The operator-driven "fresh DreamHost VPS / Fly machine walkthrough" still requires a clean cloud instance and was deferred at the Phase 11 close — see the "Verified by" section at the bottom of this doc.
| Tool | Min version | Why |
|---|---|---|
| PHP | 8.4 | The codebase uses readonly + property hooks heavily |
| Node | 20+ | Vite + bin/sgp4-passes.mjs SGP4 propagation |
| sqlite3 | 3.40+ | Schema uses RETURNING clauses + JSON1 functions |
| make | any | Wrapper for every operator-facing command |
| git | any | For cloning + bin/fetch-marquee-models.sh |
PHP extensions (verify with php -m):
pdo_sqlite— primary persistencecurl— Guzzle's HTTP backendgd— OG image generation (Phase 5 chunk 4)apcu— recommended (Phase 10 chunk 1's state-now cache falls back to a SQLite table if missing)mbstring,json— typically built-in
DreamHost's VPS tier ships Ubuntu LTS with Apache, PHP-FPM, and
cron pre-installed. The deploy story is "clone the repo, point the
docroot at public/, drop cron entries, set env vars."
# SSH to the VPS as a non-root user (DreamHost convention: dh_*).
ssh user@example.com
# Optional: confirm PHP 8.4 is available. DreamHost lets you pin
# the PHP version per domain in the control panel.
php -v
node --version
sqlite3 --versioncd ~/example.com # or wherever the domain's docroot lives
git clone https://github.com/CyberSecDef/sat.trackr.live.git app
cd app
# Composer install — no dev deps in prod.
composer install --no-dev --optimize-autoloader
# Vite build — outputs to public/build/.
npm ci
npm run build
# Optional: fetch marquee 3D models (~45 MB ISS glTF).
make fetch-modelscp .env.example .env
cp .env.prod.example .env.prod
# Edit .env.prod and fill in:
# APP_ENV=prod
# APP_URL=https://example.com
# DB_PATH=/home/user/example.com/data/sat.db
# CESIUM_ION_TOKEN=... (optional; OSM fallback works without)
# SPACE_TRACK_USER=... (chunk 4 reentries)
# SPACE_TRACK_PASS=...
# N2YO_API_KEY=... (chunk 6 magnitudes)
# LL2_API_TOKEN=... (chunk 3 launches; free tier OK)
# INGEST_ALERT_WEBHOOK_URL=... (Phase 10 chunk 2; optional Slack URL)
# WEBHOOK_SECRET=... (Phase 10 chunk 4; only if using
# push-based ingest — see below)
# PLAUSIBLE_DOMAIN=example.com (Phase 8 chunk 6; optional)
# Create the data directory the .env.prod points at, then migrate.
mkdir -p data storage/logs storage/cache
make migrate
# Seed the catalog. First run takes ~40 s for ~15K satellites.
make ingestDreamHost's web UI lets you set the "Web directory" per domain —
set it to public/ under your app dir. Behind the scenes that
edits the vhost config; the resulting Apache directive is:
<VirtualHost *:443>
ServerName example.com
DocumentRoot /home/user/example.com/app/public
<Directory /home/user/example.com/app/public>
AllowOverride All
Require all granted
# Front controller: route everything not-a-file through PHP.
FallbackResource /index.php
</Directory>
# Static caching for hashed Vite assets.
<FilesMatch "\.(js|css|woff2|png|webmanifest)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# SSL via Let's Encrypt — DreamHost auto-provisions if you
# check the box in the web UI.
SSLEngine on
</VirtualHost>Run crontab -e and paste:
# Daily 02:00 UTC — CelesTrak GP catalog refresh (~15K satellites).
0 2 * * * cd /home/user/example.com/app && make ingest
# Daily 02:30 UTC — CelesTrak SATCAT enrichment (operator / country /
# launch_date / RCS / status / decayed_at). Runs after `make ingest`
# so the catalog has every NORAD before SATCAT tries to enrich it.
# Without this row prod satellites are missing the metadata that
# powers /stats, the detail panel's "operator" line, and the chunk-4
# bright-pass discovery's RCS filter.
30 2 * * * cd /home/user/example.com/app && make ingest-satcat
# Every 6 h — Space-Track TIP messages (predicted decays).
17 */6 * * * cd /home/user/example.com/app && make ingest-spacetrack
# Twice daily — Launch Library 2 launches (~150 rows).
0 6,18 * * * cd /home/user/example.com/app && make ingest-ll2
# Every 6 h — SOCRATES conjunctions (~150K-row firehose).
0 */6 * * * cd /home/user/example.com/app && make ingest-socrates
# Every 5 min — NOAA SWPC space weather snapshot (Kp + X-ray + R/S/G).
*/5 * * * * cd /home/user/example.com/app && make ingest-swpc
# Every 15 min — NOAA OVATION aurora forecast raster.
*/15 * * * * cd /home/user/example.com/app && make ingest-ovation
# Weekly Sunday 03:00 UTC — SatNOGS amateur-radio transmitters.
0 3 * * 0 cd /home/user/example.com/app && make ingest-satnogs
# Weekly Sunday 03:30 UTC — SatNOGS amateur ground stations (Phase 10).
30 3 * * 0 cd /home/user/example.com/app && make ingest-satnogs-stations
# Daily 04:00 UTC — sitemap regeneration (Phase 5 chunk 5).
0 4 * * * cd /home/user/example.com/app && make sitemap-build
# Daily 04:30 UTC — pass-cache prune (Phase 2 chunk 6).
30 4 * * * cd /home/user/example.com/app && make pass-cache-prune
MAILTO=ops@example.comThe MAILTO= line at the bottom catches non-zero exits and mails
the operator. Phase 10 chunk 2's Slack webhook (INGEST_ALERT_WEBHOOK_URL)
adds reject-rate alerting on top of the cron-failure email.
Two parametric helpers exist for backfill / re-ingest of a single group without re-pulling the whole catalog — useful when CelesTrak publishes a fix and you want to refresh just that slice:
make ingest-group GROUP=starlink— single CelesTrak groupmake ingest-satcat-group GROUP=starlink— single SATCAT group
These are operator tools, not scheduled cron jobs; the ingest +
ingest-satcat cron rows above already cover all configured groups.
# Curl the SPA shell — expect HTML with <sat-app>.
curl -s https://example.com/ | grep '<sat-app'
# Curl the JSON API — expect a satellite count.
curl -s https://example.com/api/v1/satellites | head -c 200
# Tail the ingest log to confirm a recent run.
tail -n 20 /home/user/example.com/app/storage/logs/app.logFly.io is the documented alternative target per docs/phase10.md § II row 7. Free tier covers ~3 shared-cpu-1x machines + persistent volumes, enough for the project's single-app + cron-job pattern.
curl -L https://fly.io/install.sh | sh
flyctl auth loginCreate fly.toml at the repo root:
app = "sat-trackr-live"
primary_region = "ord" # Chicago — pick whichever is closest to you
[build]
dockerfile = "Dockerfile"
[env]
APP_ENV = "prod"
APP_URL = "https://sat-trackr-live.fly.dev"
DB_PATH = "/data/sat.db"
# Persistent volume for SQLite + the storage/ tree.
[[mounts]]
source = "sat_trackr_data"
destination = "/data"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 1
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
[deploy]
release_command = "php bin/console migrate"FROM php:8.4-fpm-alpine
# Native extensions + binaries we depend on.
RUN apk add --no-cache nginx supervisor sqlite curl bash git nodejs npm \
&& docker-php-ext-install pdo pdo_sqlite \
&& pecl install apcu && docker-php-ext-enable apcu \
&& rm -rf /var/cache/apk/*
WORKDIR /app
COPY . /app
# PHP deps + Vite build.
RUN composer install --no-dev --optimize-autoloader \
&& npm ci && npm run build && npm prune --production
# Nginx config served from public/.
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisord.conf /etc/supervisord.conf
EXPOSE 8080
CMD ["supervisord", "-c", "/etc/supervisord.conf"].git
node_modules
data
storage/cache
storage/logs
public/build
public/cesium
public/models
tests
.env
.env.*
!.env.example
!.env.*.example
Fly.io doesn't ship cron — pick one of two patterns per docs/phase10.md § IV "Fly.io ingester cron strategy":
Option A: crond inside the same machine.
Add crond to the Dockerfile + ship a crontab as a file the
image copies in. Supervisord runs nginx, php-fpm, AND
crond from one container. Simple; doesn't scale to multi-region.
# In the Dockerfile, alongside the EXPOSE line:
COPY docker/crontab /etc/crontabs/root
# In supervisord.conf, add a [program:crond] block:
# command=crond -f -l 2Option B: scheduled-app pattern.
Spin up a second Fly machine that wakes up on a schedule, runs
the single ingester, and shuts down. Use fly machine update
with --schedule="daily" or kick from a GitHub Action.
# Create a tiny worker machine that runs the daily catalog refresh.
flyctl machine create --name sat-trackr-ingest-celestrak \
--schedule daily \
--command "make ingest"The webhook receiver from Phase 10 chunk 4 (next section) is a third option that complements either of these.
Phase 10 chunk 4 adds POST /webhooks/ingest/{source} so an
external relay (e.g. a GitHub Action that watches the CelesTrak
GP feed for mtime changes) can kick a refresh on-demand:
# Set the secret in Fly + locally.
flyctl secrets set WEBHOOK_SECRET=$(openssl rand -hex 32)
# Trigger the celestrak ingester remotely.
curl -X POST \
-H "Authorization: Bearer ${WEBHOOK_SECRET}" \
https://sat-trackr-live.fly.dev/webhooks/ingest/celestrak
# → 202 Accepted, dispatch happens async in the background.Source allowlist (anything outside returns 404):
celestrak·spacetrack·ll2·socratesswpc·ovation·satnogs·satnogs-stations
Rate limit: 1 request per (source, IP) per 60 s. Receivers
behind a CDN/proxy where every request appears from the same IP
should set the rate limit higher upstream (Cloudflare WAF rules,
etc.).
Auth: bearer token in the Authorization header. Rotate
WEBHOOK_SECRET immediately if compromised — there's no
revocation list since the endpoint trusts the env var.
Off by default: with WEBHOOK_SECRET unset, every request
returns 403. The pure-cron pull flow keeps working unchanged.
flyctl launch --no-deploy --copy-config # First-time only — generates fly.toml metadata.
flyctl secrets set CESIUM_ION_TOKEN=... SPACE_TRACK_USER=... # ...etc
flyctl deploy
flyctl status
# Smoke-test from your laptop.
curl https://sat-trackr-live.fly.dev/api/v1/satellites | head -c 100| Path | What |
|---|---|
data/sat.db |
The SQLite database (Phase 1 migrations apply) |
storage/logs/app.log |
Monolog stream — every ingester writes here |
storage/logs/webhook-ingest.log |
Webhook-dispatched ingesters' stdout/stderr |
storage/cache/ingest-alerts.json |
Phase 10 chunk 2 cooldown state |
storage/cache/webhook-cooldown.json |
Phase 10 chunk 4 rate-limit state |
storage/cache/og/ |
Phase 5 chunk 4 OG-image cache (6 h TTL) |
storage/cache/sw-passes/ |
Phase 8 chunk 5 service-worker schedule mirror |
public/build/ |
Vite-built SPA bundle |
public/textures/aurora-latest.png |
Phase 4 chunk 4 raster (15-min refresh cron) |
Every secret in .env.prod is loaded once at process boot, so
rotation is a 3-step dance:
# 1. Update the env var in production (.env.prod for DreamHost,
# `flyctl secrets set` for Fly.io).
# 2. Restart PHP-FPM (DreamHost: `pkill -USR2 php-fpm` or the web
# UI's PHP reset; Fly.io: `flyctl machine restart`).
# 3. Confirm with `php -r 'echo getenv("WEBHOOK_SECRET");'` SSH'd in.WEBHOOK_SECRET is the most operationally-sensitive — a leaked
secret lets anyone trigger ingesters on your infra (DoS surface).
Phase 10 chunk 4's per-(source, IP) rate limit narrows but doesn't
close the surface; rotate fast.
| Symptom | Likely cause / fix |
|---|---|
/ renders blank, console shows "Cesium error" |
Missing CESIUM_ION_TOKEN — OSM fallback should kick in; check imagery provider in Globe.ts. |
make ingest 403s |
Cloudflare blocking the User-Agent — rotate it in CelesTrakClient. |
make ingest-satcat 404s on a SATCAT group |
CelesTrak retired the group slug — check https://celestrak.org/satcat/sources.php for the current list. |
make ingest-spacetrack 401s |
Space-Track cookie session expired; verify SPACE_TRACK_USER/PASS in .env.prod. |
/api/v1/satellites/{n}/state-now returns 500 |
Node missing or bin/sgp4-passes.mjs not executable. Verify node --version + chmod. |
/webhooks/ingest/X returns 403 with auth set |
Source slug not in the allowlist — check spelling against the 8 documented slugs. |
/webhooks/ingest/X returns 429 |
Cooldown active — wait 60 s OR check storage/cache/webhook-cooldown.json for stuck entries (manually delete to reset). |
In-repo verification (Phase 11 chunk 6, 2026-05-23, Claude Opus 4.7 with Robert Weber):
make install(composer + npm) — clean on the existing repomake build(vite production build) — 242 KB / 67.56 KB gzipped main, cleanmake migrate— 16 migrations applied, 0 pendingmake ingest— populates ~15.7K satellitesmake ci— full quality gate: lint clean, PHPStan clean (with the chunk-6 baseline frozen), TypeScript typecheck clean, PHPUnit 411/411, Vitest 361/361, security-audit advisory cleanmake test-e2e— 146 Playwright specs passing + 3 skipped (10× verify sweep at chunk-4 close hit 0 fails)- Manual curl smoke against
make serveconfirms/returns 200,/api/v1/satellites?limit=1returns a JSON envelope,/textreturns 200,/events.atomreturns 200.
Fresh-environment walkthrough (deferred): a literal "clone the
repo on a brand-new DreamHost VPS / Fly machine + follow this doc
top-to-bottom + report what didn't work" run is still pending. Per
docs/phase11.md § IV, the chunk-6 mitigation is the in-repo
verification above; the fresh-environment run is a future
operator-driven exercise. A teammate (or yourself, on a fresh
instance) is invited to add a row to this section when they do
the walkthrough.