327 lines
14 KiB
Markdown
327 lines
14 KiB
Markdown
# Production Infrastructure
|
|
|
|
> **Maintenance rule**: Update this file when infrastructure changes (hosting, databases,
|
|
> backups, monitoring, deployment pipeline, external services). Keep it factual and concise.
|
|
|
|
**CRITICAL: under no circumstances you're allowed to interact directly with
|
|
production infrastructure without asking the user first.**
|
|
|
|
## Overview
|
|
|
|
Self-hosted Phoenix application running as a Docker container orchestrated by
|
|
[Coolify](https://coolify.io). SQLite for all persistence, Litestream for continuous
|
|
backup to S3-compatible object storage. Deployed automatically on every `main` branch
|
|
push via GitHub Actions.
|
|
|
|
**Domain**: `music-library.claudio-ortolina.org`
|
|
|
|
---
|
|
|
|
## Hosting
|
|
|
|
| Component | Technology |
|
|
| ----------------- | ----------------------------------------- |
|
|
| Orchestration | Coolify (self-hosted) |
|
|
| Container runtime | Docker |
|
|
| SSL termination | Coolify reverse proxy |
|
|
| HTTP redirect | HTTP 301 → HTTPS (enforced by app config) |
|
|
|
|
The Docker image is a multi-stage build:
|
|
|
|
1. **Builder** — `hexpm/elixir:1.20.0-rc.5-erlang-28.5-debian-trixie-20260505-slim` with
|
|
Node.js 26, compiles deps, builds assets (`mix assets.deploy`), generates an OTP release.
|
|
2. **Runner** — `debian:trixie-20260505-slim` with minimal runtime deps (`libstdc++6`,
|
|
`openssl`, `libncurses6`, `ca-certificates`). Runs as unprivileged `nobody` user.
|
|
|
|
Fluxon UI (licensed dependency) is fetched during build via Docker build secrets
|
|
(`FLUXON_LICENSE_KEY`, `FLUXON_KEY_FINGERPRINT`).
|
|
|
|
---
|
|
|
|
## Databases
|
|
|
|
Three separate SQLite databases, each managed by its own Ecto repo:
|
|
|
|
| Repo | Purpose | Cache size | Pool size |
|
|
| ----------------------------- | ---------------------------- | ---------- | ------------------------ |
|
|
| `MusicLibrary.Repo` | Application data | 128 MB | `$POOL_SIZE` (default 5) |
|
|
| `MusicLibrary.BackgroundRepo` | Oban job queue | 16 MB | `$POOL_SIZE` (default 5) |
|
|
| `MusicLibrary.TelemetryRepo` | Persistent telemetry metrics | 4 MB | 2 |
|
|
|
|
All databases use incremental auto-vacuum. Paths are configured via environment variables
|
|
(`DATABASE_PATH`, `BACKGROUND_DATABASE_PATH`, `TELEMETRY_DATABASE_PATH`).
|
|
|
|
---
|
|
|
|
## Backups
|
|
|
|
### Litestream (continuous)
|
|
|
|
Configured inline in `compose.yaml`. Runs as a separate Docker Compose service
|
|
(`litestream/litestream:0.5.11-scratch`) sharing the database volume.
|
|
|
|
| Setting | Value |
|
|
| ------------- | --------------------------------------------------------- |
|
|
| S3 endpoint | `https://nbg1.your-objectstorage.com` |
|
|
| Bucket | `ffmusiclibrary` |
|
|
| Sync interval | 60 minutes |
|
|
| Retention | 672 hours (28 days) |
|
|
| Healthcheck | `litestream databases` every 30s (timeout 10s, 3 retries) |
|
|
|
|
Credentials via environment: `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY`.
|
|
|
|
### Manual backup
|
|
|
|
Two manual options are available:
|
|
|
|
- `mise run prod:backup` — takes an atomic snapshot of the live database on the
|
|
production server using `sqlite3 .backup`, then rsyncs it locally as
|
|
`data/music_library_prod_<timestamp>.db`. Safe under concurrent writes.
|
|
- `mise run prod:litestream-backup` — restores the production database locally from
|
|
the S3 Litestream replica. Requires `LITESTREAM_ACCESS_KEY_ID` and
|
|
`LITESTREAM_SECRET_ACCESS_KEY`.
|
|
|
|
Old backups can be cleaned with `mise run prod:prune-backups`.
|
|
|
|
---
|
|
|
|
## Deployment Pipeline
|
|
|
|
### CI/CD (GitHub Actions)
|
|
|
|
Workflow: `.github/workflows/test_and_deploy.yml`
|
|
|
|
Triggers: push to `main`/tags, pull requests, manual `workflow_dispatch`.
|
|
Concurrency control cancels in-progress runs for the same ref.
|
|
Uses `mise` (via `jdx/mise-action@v4`) for tool version management.
|
|
|
|
```
|
|
Push to main (or PR / manual dispatch)
|
|
├── Lint (format, gettext, credo, sobelow, mix_audit, shellcheck, docker image, asset build)
|
|
├── Test (mix test with partitioning, coverage ≥75%)
|
|
└── Deploy (requires GitHub environment approval, main branch only)
|
|
├── Trigger deployment via Coolify API (hurl)
|
|
├── Wait for container health check
|
|
└── Run post-deploy verification (test/prod.hurl)
|
|
```
|
|
|
|
A manual verification workflow (`.github/workflows/verify.yml`) can be triggered via
|
|
`workflow_dispatch` to re-run production checks without deploying.
|
|
|
|
### Dependency management (Dependabot)
|
|
|
|
Automated dependency updates via GitHub Dependabot (`.github/dependabot.yml`):
|
|
|
|
- **Docker (Dockerfile)**: daily checks for builder/runner base image updates, max 5 open PRs
|
|
- **Docker Compose**: daily checks for service image updates
|
|
- **Elixir (mix)**: daily checks, max 10 open PRs
|
|
- **NPM**: daily checks, max 10 open PRs, ignores path-based local deps
|
|
- **GitHub Actions**: daily checks for action version updates
|
|
|
|
Fluxon (private dependency) is configured with a dedicated registry entry.
|
|
|
|
### Post-deploy verification
|
|
|
|
`test/prod.hurl` checks:
|
|
|
|
- HTTP → HTTPS redirect
|
|
- API authentication enforcement
|
|
- Valid API responses with auth token
|
|
- Dev routes require login
|
|
- Bot scanner paths (`wp-admin`, `.env`, `xmlrpc`) return 404
|
|
|
|
### Deployment credentials
|
|
|
|
| Secret/Variable | Purpose |
|
|
| ------------------ | ---------------------------------------- |
|
|
| `COOLIFY_TOKEN` | API authentication (GitHub secret) |
|
|
| `COOLIFY_HOST` | Coolify server address (GitHub variable) |
|
|
| `COOLIFY_APP_UUID` | Application identifier (GitHub variable) |
|
|
|
|
---
|
|
|
|
## Environment Variables
|
|
|
|
### Required
|
|
|
|
| Variable | Purpose |
|
|
| -------------------------- | ---------------------------------------------------- |
|
|
| `DATABASE_PATH` | Absolute path to main SQLite database |
|
|
| `BACKGROUND_DATABASE_PATH` | Absolute path to background jobs database |
|
|
| `TELEMETRY_DATABASE_PATH` | Absolute path to telemetry database |
|
|
| `SECRET_KEY_BASE` | Phoenix secret (`mix phx.gen.secret`) |
|
|
| `CLOAK_ENCRYPTION_KEY` | Base64-encoded 32-byte AES key for encrypted secrets |
|
|
| `LOGIN_PASSWORD` | Web login password |
|
|
| `API_TOKEN` | Bearer token for API endpoints |
|
|
| `MAILGUN_API_KEY` | Mailgun API key |
|
|
|
|
### Optional
|
|
|
|
| Variable | Default | Purpose |
|
|
| ------------------------------- | ------------------------- | ----------------------------- |
|
|
| `SERVICE_FQDN_WEB` | `example.com` | Application domain |
|
|
| `PORT` | `4000` | HTTP listen port |
|
|
| `POOL_SIZE` | `5` | Database connection pool size |
|
|
| `MAILGUN_DOMAIN` | `mailgun.fullyforged.com` | Mailgun sending domain |
|
|
| `DEFAULT_TIMEZONE` | `Europe/London` | Application timezone |
|
|
| `OPENAI_KEY` | — | OpenAI API (embeddings, chat) |
|
|
| `DISCOGS_PERSONAL_ACCESS_TOKEN` | — | Discogs API |
|
|
| `BRAVE_SEARCH_API_KEY` | — | Brave Search API |
|
|
| `LAST_FM_API_KEY` | — | Last.fm API |
|
|
| `LAST_FM_SHARED_SECRET` | — | Last.fm scrobbling auth |
|
|
| `LAST_FM_USER` | — | Last.fm username |
|
|
|
|
---
|
|
|
|
## OTP Release
|
|
|
|
Standard Mix release. Entry point: `rel/overlays/bin/server` (sets `PHX_SERVER=true`).
|
|
|
|
Migrations are skipped during release boot (`skip_migrations?/0` returns `true` when
|
|
`RELEASE_NAME` is set). Instead, Coolify is configured to run migrations after the
|
|
Docker image is built and the container is started — this happens via the
|
|
`rel/overlays/bin/migrate` script, executed as a post-deployment command in Coolify
|
|
before the application begins serving traffic.
|
|
|
|
A standalone `rel/overlays/bin/migrate` script is also available for manual use.
|
|
|
|
ERL_FLAGS: `+JPperf true` (JIT performance monitoring).
|
|
|
|
---
|
|
|
|
## Monitoring & Observability
|
|
|
|
### Health check
|
|
|
|
`GET /health` — queries the main database, returns 200 or 500. Used by Docker health
|
|
checks and post-deploy verification.
|
|
|
|
### Logging
|
|
|
|
Production logs are configured for single-line output so every physical log line
|
|
corresponds to exactly one log event. This makes log files reliably filterable with
|
|
line-oriented tools (grep, tail, sort) and enables deterministic reverse-order reading.
|
|
|
|
Three layers work together:
|
|
|
|
1. **Logster v2** — Replaces `Phoenix.Logger` for HTTP request and LiveView socket
|
|
telemetry. Merges `GET + Sent` into a single logfmt line with `method`, `path`,
|
|
`status`, `duration`, and `request_id` fields. Handles `[:phoenix, :socket_connected]`
|
|
telemetry, flattening the multi-line handshake output into one line.
|
|
|
|
2. **Custom formatter** (`MusicLibrary.Logger.SingleLineFormatter`) — Safety net that
|
|
replaces any remaining embedded newlines (`\n`) with escaped `\\n` in ALL log
|
|
messages. Catches stack traces, Erlang runtime messages, and any multi-line strings
|
|
that Logster does not cover. Configured in `config/prod.exs`.
|
|
|
|
3. **Config flag** (`single_line_logging`) — Boolean in `config/config.exs` (default
|
|
`false`) and overridden to `true` in `config/prod.exs`. Controls `Logster.attach_phoenix_logger()`
|
|
in `application.ex`. Dev/test environments keep the default multi-line format for readability.
|
|
|
|
Configuration summary:
|
|
|
|
```elixir
|
|
# config/prod.exs
|
|
config :phoenix, :logger, false
|
|
config :music_library, :single_line_logging, true
|
|
|
|
config :logster,
|
|
extra_fields: [:request_id],
|
|
filter_parameters: Application.get_env(:phoenix, :filter_parameters, ["password", "token"])
|
|
|
|
config :logger, :default_formatter,
|
|
format: {MusicLibrary.Logger.SingleLineFormatter, :format},
|
|
metadata: [:request_id, :pid]
|
|
```
|
|
|
|
Dev environment retains the default `"$time $metadata[$level] $message\n"` format with
|
|
`Phoenix.Logger` active.
|
|
|
|
### Error tracking
|
|
|
|
`ErrorTracker` with email notifications via Mailgun:
|
|
|
|
- Listens to `:error_tracker` telemetry events
|
|
- Throttles repeated error notifications
|
|
- Non-actionable errors (bot scanners, `NoRouteError`) filtered via `ErrorIgnorer`
|
|
- Muted errors skip email notifications
|
|
|
|
### Telemetry
|
|
|
|
SQLite-backed persistent metrics (`MusicLibraryWeb.Telemetry.Storage`) with 30-second
|
|
polling interval. Events are buffered in GenServer state keyed by metric and flushed to
|
|
SQLite every 5 seconds inside a single transaction; reads via `metrics_history/1`
|
|
force-flush only the requested metric so the dashboard sees fresh data without waiting
|
|
for the next tick. Per-metric retention is capped at 32 768 rows
|
|
(`:retention_limit`), pruned after each flush. Flush failures are logged at `:warning`
|
|
and the offending batch is dropped. Tracks:
|
|
|
|
- Database query times (total, query, queue)
|
|
- External API request latency (Finch)
|
|
- Rate limiter throttle durations
|
|
- Asset cache hit/miss
|
|
- Oban job metrics (enqueue, execute, attempt, discard)
|
|
- Scrobble rules processing (duration, exceptions)
|
|
- Error tracker counters
|
|
|
|
### Dashboards (behind auth)
|
|
|
|
- Phoenix LiveDashboard (`/dev/dashboard`)
|
|
- Oban Web (`/dev/oban`)
|
|
- ErrorTracker (`/dev/errors`)
|
|
|
|
### Pi coding agent tools
|
|
|
|
Pi extensions provide additional tools for production observability without manual SSH
|
|
or browser access. Each extension reads its own environment variables from the pi
|
|
runtime environment (not server-side config).
|
|
|
|
| Extension | Tools | Env vars |
|
|
| ------------- | ------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
| `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` |
|
|
| `prod-errors` | `fetch_production_errors`, `fetch_production_error`, `/prod-errors` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` |
|
|
|
|
**`prod-logs` env vars:**
|
|
|
|
- `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`)
|
|
- `PI_COOLIFY_APP_UUID` — Application UUID in Coolify
|
|
- `PI_COOLIFY_TOKEN` — Coolify API Bearer token
|
|
|
|
**`prod-errors` tools and command:**
|
|
|
|
- `fetch_production_errors` — List/filter errors via LLM tool
|
|
- `fetch_production_error` — Single error detail via LLM tool
|
|
- `/prod-errors` — Interactive TUI for browsing errors (list, detail, filter toggles)
|
|
|
|
**`prod-errors` env vars:**
|
|
|
|
- `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`)
|
|
- `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash)
|
|
|
|
---
|
|
|
|
## Email
|
|
|
|
Mailgun via Swoosh (`Swoosh.Adapters.Mailgun`).
|
|
|
|
| Setting | Value |
|
|
| ------- | ------------------------------------ |
|
|
| From | `postmaster@mailgun.fullyforged.com` |
|
|
| To | `claudio@fullyforged.com` |
|
|
|
|
Used for error notifications and the daily "records on this day" digest email.
|
|
|
|
---
|
|
|
|
## Encryption
|
|
|
|
Cloak vault (`MusicLibrary.Vault`) with AES.GCM cipher for at-rest encryption of
|
|
secrets stored in the `secrets` table. Key configured via `CLOAK_ENCRYPTION_KEY`
|
|
(base64-encoded 32-byte key).
|
|
|
|
---
|
|
|
|
## Tool Versions
|
|
|
|
Defined in `mise.toml`.
|