spiderly 19.8.4 → 19.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/agent/docs/angular-customization/SKILL.md +389 -0
  2. package/agent/docs/angular-customization/references/controls.generated.md +23 -0
  3. package/agent/docs/angular-customization/references/helper-functions.generated.md +39 -0
  4. package/agent/docs/angular-customization/references/ui-control-types.generated.md +24 -0
  5. package/agent/docs/angular-customization/references/validators.generated.md +13 -0
  6. package/agent/docs/authorization/SKILL.md +385 -0
  7. package/agent/docs/authorization/references/api-error-codes.generated.md +17 -0
  8. package/agent/docs/authorization/references/security-endpoints.generated.md +24 -0
  9. package/agent/docs/backend-hooks/SKILL.md +231 -0
  10. package/agent/docs/backend-localization/SKILL.md +170 -0
  11. package/agent/docs/backend-testing/SKILL.md +65 -0
  12. package/agent/docs/custom-endpoints/SKILL.md +409 -0
  13. package/agent/docs/e2e-testing/SKILL.md +139 -0
  14. package/agent/docs/entity-design/SKILL.md +346 -0
  15. package/agent/docs/entity-design/references/attributes.generated.md +53 -0
  16. package/agent/docs/file-storage/SKILL.md +262 -0
  17. package/agent/docs/filtering-patterns/SKILL.md +127 -0
  18. package/agent/docs/filtering-patterns/references/match-mode-codes.generated.md +15 -0
  19. package/agent/docs/frontend-localization/SKILL.md +120 -0
  20. package/agent/docs/mapper-customization/SKILL.md +105 -0
  21. package/agent/manifest.json +34 -0
  22. package/agent/skills/add-entity/SKILL.md +158 -0
  23. package/agent/skills/deployment/SKILL.md +551 -0
  24. package/agent/skills/ef-migrations/SKILL.md +49 -0
  25. package/agent/skills/report-gap/SKILL.md +110 -0
  26. package/agent/skills/report-gap/scripts/build-issue-url.mjs +82 -0
  27. package/agent/skills/spiderly-upgrade/SKILL.md +165 -0
  28. package/agent/skills/verify-ui/SKILL.md +148 -0
  29. package/agent/skills/verify-ui/scripts/get-admin-token.mjs +134 -0
  30. package/fesm2022/spiderly.mjs +11 -6
  31. package/fesm2022/spiderly.mjs.map +1 -1
  32. package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +29 -3
  33. package/package.json +1 -1
@@ -0,0 +1,551 @@
1
+ ---
2
+ name: deployment
3
+ description: Deploy a Spiderly project to your own infrastructure with Docker, Caddy, and Terraform. Use when deploying, redeploying, shipping, releasing, or rolling out the .NET backend or Angular admin to production — first-time setup or an ongoing deploy — and when diagnosing a down or erroring production origin (502/521, container crash-loop, failed deploy workflow). Also covers CI/CD pipelines, TLS with Cloudflare origin certificates, database backups and restores, and infrastructure-as-code layout for a Spiderly app.
4
+ ---
5
+
6
+ # Deployment
7
+
8
+ Spiderly is Docker-first by design. The recommended production setup is a single VPS running Docker Compose, fronted by Caddy and Cloudflare, with all infrastructure declared in Terraform.
9
+
10
+ ## Recommended stack
11
+
12
+ | Tier | Choice | Why |
13
+ |---|---|---|
14
+ | Compute | **Hetzner Cloud** (or any VPS) | Predictable monthly cost, full control, no PaaS lock-in |
15
+ | Orchestration | **Docker Compose** | Single-host simplicity; matches Spiderly's Docker-first philosophy |
16
+ | Reverse proxy / TLS | **Caddy v2** | Auto-config from Cloudflare origin certs, simple Caddyfile |
17
+ | DNS / WAF / CDN | **Cloudflare** (orange-cloud) | DDoS protection, origin certs, Turnstile |
18
+ | IaC | **Terraform** | Declarative; one source of truth for VPS + DNS + certs |
19
+ | State backend | **Cloudflare R2** | S3-compatible, free tier, encrypted at rest |
20
+ | Container registry | **GitHub Container Registry (GHCR)** | Free for public/internal repos, native to GitHub Actions |
21
+ | CI/CD | **GitHub Actions** | Build, push, SSH-deploy in one workflow file |
22
+
23
+ ## What to host where
24
+
25
+ - **.NET Backend** → Hetzner+Docker (recommended)
26
+ - **Angular admin** → Hetzner+Docker, **same VPS** as the backend, served by the same Caddy on a separate subdomain
27
+ - **Next.js storefront (if applicable)** → **Vercel recommended**. Next.js + Vercel gives you ISR, edge caching, image optimization, PPR, and instant preview URLs. Hetzner+Docker for SSR is viable but loses these features. Use Hetzner only if you need a single ops surface.
28
+
29
+ ## Compose stack shape
30
+
31
+ `Backend/docker-compose.prod.yml` (placed alongside the backend project):
32
+
33
+ ```yaml
34
+ services:
35
+ caddy:
36
+ image: caddy:2
37
+ command: caddy run --config /etc/caddy/Caddyfile --watch
38
+ ports: ["80:80", "443:443"]
39
+ volumes:
40
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
41
+ - ./certs:/etc/caddy/certs:ro
42
+ - caddy_data:/data
43
+ depends_on:
44
+ backend:
45
+ condition: service_started
46
+ admin:
47
+ condition: service_healthy
48
+ restart: unless-stopped
49
+
50
+ backend:
51
+ image: ${BACKEND_IMAGE}
52
+ environment:
53
+ ASPNETCORE_ENVIRONMENT: Production
54
+ ASPNETCORE_HTTP_PORTS: "8080"
55
+ AppSettings__Spiderly.Shared__ConnectionString: "Host=postgres;Port=5432;Database=${DB_NAME};Username=postgres;Password=${DB_PASSWORD};SSL Mode=Disable;"
56
+ # ... plus storage / mail / OAuth env vars (see file-storage skill for the storage set)
57
+ healthcheck:
58
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
59
+ interval: 30s
60
+ timeout: 5s
61
+ start_period: 60s
62
+ retries: 3
63
+ depends_on:
64
+ postgres: { condition: service_healthy }
65
+ restart: unless-stopped
66
+
67
+ admin:
68
+ image: ${ADMIN_IMAGE}
69
+ expose: ["80"]
70
+ healthcheck:
71
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
72
+ interval: 30s
73
+ timeout: 5s
74
+ start_period: 10s
75
+ retries: 3
76
+ restart: unless-stopped
77
+
78
+ postgres:
79
+ image: postgres:18
80
+ environment:
81
+ POSTGRES_DB: ${DB_NAME}
82
+ POSTGRES_USER: postgres
83
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
84
+ volumes: [postgres_data:/var/lib/postgresql] # 18+ layout — see Key points
85
+ healthcheck:
86
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
87
+ interval: 10s
88
+ retries: 5
89
+ restart: unless-stopped
90
+
91
+ volumes:
92
+ caddy_data:
93
+ postgres_data:
94
+ ```
95
+
96
+ **Key points:**
97
+ - Only `caddy` binds host ports. `backend` and `admin` are reachable only on the internal Docker network — Caddy reverse-proxies to them by service name.
98
+ - `caddy.depends_on` uses `condition: service_healthy` for the admin container (so Caddy doesn't 502 before the inner Caddy has bound port 80) but `condition: service_started` for the backend — the backend's `/health` endpoint only goes green after EF migrations and warmup, and gating Caddy on that would block all traffic for 20–30 s on every restart.
99
+ - Image tags come from CI via envsubst (`${BACKEND_IMAGE}`, `${ADMIN_IMAGE}`).
100
+ - **postgres:18+ mounts at `/var/lib/postgresql`, not `/var/lib/postgresql/data`.** The 18 image moved data into a major-version subdirectory (`PGDATA=/var/lib/postgresql/18/docker`). With the pre-18 mount path the entrypoint hard-errors ("there appears to be PostgreSQL data in /var/lib/postgresql/data") and the container crash-loops — and even when it starts, data lands in an anonymous volume and does not survive container recreation.
101
+
102
+ ## Caddy site blocks
103
+
104
+ `Backend/Caddyfile` — extracts compression + security headers into a `(common)` snippet so both site blocks stay DRY. Cloudflare also sets some of these (HSTS, Brotli) at its edge, but defense-in-depth at the origin is cheap and keeps origin→Cloudflare bandwidth compressed:
105
+
106
+ ```
107
+ (common) {
108
+ encode zstd gzip
109
+ header {
110
+ Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
111
+ X-Content-Type-Options nosniff
112
+ X-Frame-Options DENY
113
+ Referrer-Policy strict-origin-when-cross-origin
114
+ -Server
115
+ }
116
+ }
117
+
118
+ api.<your-domain> {
119
+ import common
120
+ tls /etc/caddy/certs/origin.pem /etc/caddy/certs/origin-key.pem
121
+ reverse_proxy backend:8080
122
+ }
123
+
124
+ admin.<your-domain> {
125
+ import common
126
+ tls /etc/caddy/certs/origin.pem /etc/caddy/certs/origin-key.pem
127
+ reverse_proxy admin:80
128
+ }
129
+ ```
130
+
131
+ Both subdomains share a single Cloudflare origin cert with both names as SANs (set up in Terraform — see below).
132
+
133
+ ## Backend Dockerfile
134
+
135
+ `Backend/Dockerfile` (multi-stage SDK build → aspnet runtime):
136
+
137
+ ```dockerfile
138
+ FROM mcr.microsoft.com/dotnet/sdk:9.0.102 AS build
139
+ WORKDIR /src
140
+ COPY ["<YourApp>.WebAPI/<YourApp>.WebAPI.csproj", "<YourApp>.WebAPI/"]
141
+ # ... copy other csprojs, restore, copy source, publish ...
142
+ RUN dotnet publish "<YourApp>.WebAPI/<YourApp>.WebAPI.csproj" -c Release -o /app/publish /p:UseAppHost=false
143
+
144
+ FROM mcr.microsoft.com/dotnet/aspnet:9.0.1
145
+ WORKDIR /app
146
+ RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
147
+ COPY --from=publish /app/publish .
148
+ USER app
149
+ EXPOSE 8080
150
+ ENTRYPOINT ["dotnet", "<YourApp>.WebAPI.dll"]
151
+ ```
152
+
153
+ **Pin patch versions** (`9.0.102` SDK, `9.0.1` runtime) — `:9.0` floats and breaks reproducible builds.
154
+
155
+ **Run as non-root.** The aspnet image ships an `app` user (UID 1654). Add `USER app` after `COPY` for defense-in-depth — limits the blast radius of a container escape and matches the platform's sandbox model.
156
+
157
+ **Log volumes:** if you're using a Serilog File sink (not just Console) with a Hangfire log-archival job, see the **Backups → Log archival** section below for the full bind-mount-vs-Console-only trade-off. Greenfield deploys can stick with Console sink + Docker's `json-file` rotation and skip the volume entirely.
158
+
159
+ ## Angular admin Dockerfile
160
+
161
+ `Frontend/Dockerfile` (multi-stage Node build → Caddy alpine runtime):
162
+
163
+ ```dockerfile
164
+ FROM node:22-alpine AS build
165
+ WORKDIR /app
166
+ COPY package.json package-lock.json ./
167
+ RUN npm ci
168
+ COPY . .
169
+ RUN npm run build
170
+
171
+ FROM caddy:2-alpine
172
+ COPY --from=build /app/dist/<YourApp>/browser /srv
173
+ COPY Caddyfile /etc/caddy/Caddyfile
174
+ EXPOSE 80
175
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
176
+ CMD wget -q --spider http://localhost/ || exit 1
177
+ ```
178
+
179
+ `Frontend/Caddyfile` (internal — runs inside the admin container):
180
+
181
+ ```
182
+ :80 {
183
+ root * /srv
184
+ encode zstd gzip
185
+
186
+ @hashedAssets {
187
+ path_regexp hashed \.[a-f0-9]{8,}\.(js|css|woff2?|ttf|otf|svg|png|jpg|jpeg|webp|ico)$
188
+ }
189
+ header @hashedAssets Cache-Control "public, max-age=31536000, immutable"
190
+
191
+ @indexHtml path /index.html
192
+ header @indexHtml Cache-Control "no-cache, no-store, must-revalidate"
193
+
194
+ try_files {path} /index.html
195
+ file_server
196
+ }
197
+ ```
198
+
199
+ The `try_files {path} /index.html` line is the SPA fallback that lets the Angular router handle deep links.
200
+
201
+ **Lockfile gotcha:** `npm ci` requires `package.json` and `package-lock.json` to be in sync. If a stale dev-dep pins an older Angular major as a peer (e.g. `@jsverse/transloco-keys-manager` 5.x pins Angular 17 while your project is on Angular 19), `npm ci` fails. Bump the dev-dep to the matching major (e.g. `transloco-keys-manager` 6.x for Angular 19) rather than reaching for `--legacy-peer-deps`.
202
+
203
+ ## Terraform layout
204
+
205
+ Split files by provider concern; one provider, one or two files:
206
+
207
+ ```
208
+ infrastructure/
209
+ ├── main.tf # required_providers + state backend
210
+ ├── variables.tf
211
+ ├── outputs.tf
212
+ ├── hetzner-firewall.tf # firewall, allowed ports
213
+ ├── cloudflare-dns.tf # api/admin A records
214
+ ├── cloudflare-origin-cert.tf # origin CA cert + private key
215
+ └── cloudflare-zone.tf # zone + headers
216
+ ```
217
+
218
+ `main.tf` providers:
219
+
220
+ ```hcl
221
+ terraform {
222
+ required_version = ">= 1.5"
223
+
224
+ backend "s3" {
225
+ # Cloudflare R2 — S3-compatible
226
+ bucket = "<your-app>-terraform-state"
227
+ key = "infrastructure/terraform.tfstate"
228
+ region = "auto"
229
+ skip_credentials_validation = true
230
+ skip_metadata_api_check = true
231
+ skip_region_validation = true
232
+ skip_requesting_account_id = true
233
+ skip_s3_checksum = true
234
+ endpoints = { s3 = "https://<account-id>.r2.cloudflarestorage.com" }
235
+ }
236
+
237
+ required_providers {
238
+ cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.0" }
239
+ hcloud = { source = "hetznercloud/hcloud", version = "~> 1.49" }
240
+ tls = { source = "hashicorp/tls", version = "~> 4.0" }
241
+ }
242
+ }
243
+ ```
244
+
245
+ Origin cert covering both `api` and `admin` subdomains (`cloudflare-origin-cert.tf`):
246
+
247
+ ```hcl
248
+ resource "tls_private_key" "origin" {
249
+ algorithm = "RSA"
250
+ rsa_bits = 2048
251
+
252
+ lifecycle {
253
+ prevent_destroy = true
254
+ }
255
+ }
256
+
257
+ resource "tls_cert_request" "origin" {
258
+ private_key_pem = tls_private_key.origin.private_key_pem
259
+
260
+ subject {
261
+ common_name = "<your-domain>"
262
+ organization = "<YourApp>"
263
+ }
264
+
265
+ dns_names = [
266
+ "api.<your-domain>",
267
+ "admin.<your-domain>",
268
+ ]
269
+ }
270
+
271
+ resource "cloudflare_origin_ca_certificate" "origin" {
272
+ csr = tls_cert_request.origin.cert_request_pem
273
+ hostnames = tls_cert_request.origin.dns_names
274
+ request_type = "origin-rsa"
275
+ requested_validity = 5475 # 15 years
276
+
277
+ lifecycle {
278
+ prevent_destroy = true
279
+ # Cloudflare normalizes hostname/CSR ordering server-side; ignore both to avoid spurious recreate.
280
+ ignore_changes = [hostnames, csr]
281
+ }
282
+ }
283
+ ```
284
+
285
+ Sensitive outputs (`outputs.tf`) so the deploy workflow can pull cert + key into GitHub Secrets:
286
+
287
+ ```hcl
288
+ output "origin_cert" {
289
+ value = cloudflare_origin_ca_certificate.origin.certificate
290
+ sensitive = true
291
+ }
292
+ output "origin_cert_key" {
293
+ value = tls_private_key.origin.private_key_pem
294
+ sensitive = true
295
+ }
296
+ ```
297
+
298
+ Retrieve with `terraform output -raw origin_cert` and paste into a GitHub Secret. **Note:** the API token must have `Origin CA: Edit` scope for `cloudflare_origin_ca_certificate` to work.
299
+
300
+ ## CI/CD
301
+
302
+ Two workflows — one per deploy unit. Both should share a `concurrency` group so they serialize on the same VPS:
303
+
304
+ ```yaml
305
+ # .github/workflows/deploy-backend.yml
306
+ name: Deploy Backend
307
+ on:
308
+ push:
309
+ branches: [main]
310
+ paths: ['Backend/**', '.github/workflows/deploy-backend.yml']
311
+
312
+ concurrency:
313
+ group: <your-app>-deploy
314
+ cancel-in-progress: false
315
+
316
+ env:
317
+ IMAGE_NAME: ghcr.io/<your-user>/<your-app>-backend
318
+ ADMIN_IMAGE_NAME: ghcr.io/<your-user>/<your-app>-admin
319
+ ```
320
+
321
+ Steps (one idempotent sequence — the first cutover on an empty VPS and every later deploy share the same path):
322
+
323
+ 1. **Run tests** (gate the deploy on green tests).
324
+ 2. **Build + push image** to GHCR (`docker/build-push-action@v6` with GHA cache).
325
+ 3. **SSH key setup** + `ssh-keyscan` to trust the host.
326
+ 4. **Sync compose + Caddyfile** with `envsubst` to inject image tags + secrets, then `scp` to `/opt/<your-app>/`.
327
+ 5. **Start Postgres and wait for health**: `ssh ... "docker compose up -d --wait postgres"`. A no-op when it's already running; on a fresh VPS — or after the DB container was recreated — this is what makes the migration step possible at all.
328
+ 6. **Run EF migrations** via SSH tunnel to the VPS Postgres port (so prod schema updates before the new backend starts). Set the cleanup trap *before* opening the tunnel so an early ssh failure doesn't leak the trap:
329
+ ```bash
330
+ trap 'pkill -f "ssh -o ExitOnForwardFailure=yes -fN -L 5432" || true' EXIT
331
+ ssh -o ExitOnForwardFailure=yes -fN -L 5432:127.0.0.1:5432 root@host
332
+ for i in {1..30}; do nc -z localhost 5432 && break; sleep 1; done
333
+ nc -z localhost 5432 || { echo "::error::SSH tunnel never came up after 30s"; exit 1; }
334
+ ```
335
+ The explicit `nc` check after the loop is what turns a timed-out tunnel into a clear failure — without it, `dotnet ef` runs against a dead port and reports a confusing "connection refused" instead.
336
+ 7. **Deploy**: `ssh ... "docker compose pull backend && docker compose up -d backend caddy"`. Scope to just the services you're updating — unscoped `up -d` will also bounce admin/postgres on every backend push, which is rarely what you want.
337
+
338
+ **Why this order matters:** running migrations before the compose file is synced and Postgres is started only works on a box where Postgres already happens to be running — that's a steady-state-only pipeline with a hidden manual first bootstrap, and it re-breaks whenever the DB container is recreated. Sync → start DB (wait healthy) → migrate → start app works on an empty server and on every routine deploy alike, so there is never a special first-cutover procedure to remember. (One remaining first-deploy dependency: `${ADMIN_IMAGE}` must already exist in GHCR — see the *First deploy ordering* pitfall.)
339
+
340
+ The admin workflow is similar but lighter: build → push → ssh → `docker compose pull admin && docker compose up -d admin`. **Don't** restart Caddy after an admin update — Caddy resolves `admin:80` via Docker DNS at request time and picks up the new container automatically.
341
+
342
+ For migration mechanics (creating migrations, the dedicated `*.Migrations` startup-project pattern, why direct DDL on prod is forbidden), see the `ef-migrations` skill.
343
+
344
+ ## Backups
345
+
346
+ If you ship this stack to prod, you ship a backup with it. Postgres data lives in a Docker volume on a single VPS — disk failure, accidental `docker volume rm`, or `terraform destroy` all wipe it without a snapshot to restore from.
347
+
348
+ ### Database backups (required)
349
+
350
+ Daily `pg_dump` to a Cloudflare R2 bucket, 7-day retention both local and remote. ~24h RPO; if you need tighter, layer WAL archiving on top (separate skill).
351
+
352
+ **1. R2 bucket — Terraform-managed.** No chicken-and-egg here (only the *state* bucket has to be bootstrapped manually); manage data buckets like any other resource:
353
+
354
+ ```hcl
355
+ # infrastructure/cloudflare-r2.tf
356
+ resource "cloudflare_r2_bucket" "db_backups" {
357
+ account_id = var.cloudflare_account_id
358
+ name = "<your-app>-db-backups"
359
+ location = "EEUR"
360
+
361
+ lifecycle {
362
+ prevent_destroy = true
363
+ }
364
+ }
365
+ ```
366
+
367
+ **2. Backup script — `infrastructure/scripts/pg_backup_s3.sh`** (deployed to `/usr/local/bin/` on the VPS). Note the `trap` cleanup, the `aws s3api list-objects-v2 --query` for retention (structured + locale-safe vs parsing `aws s3 ls` with awk), and the per-iteration warn-on-failure inside the `while` subshell (where `set -e` does NOT propagate):
368
+
369
+ ```bash
370
+ #!/usr/bin/env bash
371
+ set -euo pipefail
372
+
373
+ CONTAINER_NAME="<your-app>-postgres-1"
374
+ DB_NAME="<your-db>"
375
+ DB_USER="postgres"
376
+ CLOUDFLARE_ACCOUNT_ID="<account-id>"
377
+ S3_BUCKET="s3://<your-app>-db-backups"
378
+ R2_ENDPOINT="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
379
+ RETENTION_DAYS=7
380
+ LOCAL_BACKUP_DIR="/var/backups/postgresql"
381
+ LOG_FILE="/var/log/<your-app>_pg_backup.log"
382
+
383
+ TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
384
+ BACKUP_FILE="<your-app>_${TIMESTAMP}.sql.gz"
385
+ LOCAL_PATH="${LOCAL_BACKUP_DIR}/${BACKUP_FILE}"
386
+
387
+ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"; }
388
+
389
+ trap 'rc=$?; [[ $rc -ne 0 ]] && rm -f "$LOCAL_PATH" && log "Aborted, removed partial $LOCAL_PATH"; exit $rc' ERR INT TERM
390
+
391
+ mkdir -p "$LOCAL_BACKUP_DIR"
392
+
393
+ if ! docker exec "$CONTAINER_NAME" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$LOCAL_PATH"; then
394
+ log "ERROR: pg_dump failed"; exit 1
395
+ fi
396
+ log "Backup created: ${LOCAL_PATH} ($(du -h "$LOCAL_PATH" | cut -f1))"
397
+
398
+ if ! aws s3 --endpoint-url "$R2_ENDPOINT" cp "$LOCAL_PATH" "${S3_BUCKET}/${BACKUP_FILE}"; then
399
+ log "ERROR: S3 upload failed"; exit 1
400
+ fi
401
+ log "Uploaded to ${S3_BUCKET}/${BACKUP_FILE}"
402
+
403
+ find "$LOCAL_BACKUP_DIR" -name "<your-app>_*.sql.gz" -mtime +"$RETENTION_DAYS" -delete
404
+
405
+ CUTOFF_ISO=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)
406
+ BUCKET_NAME="${S3_BUCKET#s3://}"
407
+ OLD_KEYS=$(aws s3api --endpoint-url "$R2_ENDPOINT" list-objects-v2 \
408
+ --bucket "$BUCKET_NAME" \
409
+ --query "Contents[?LastModified<'${CUTOFF_ISO}'].Key" \
410
+ --output text 2>/dev/null || echo "")
411
+ if [[ -n "$OLD_KEYS" && "$OLD_KEYS" != "None" ]]; then
412
+ for KEY in $OLD_KEYS; do
413
+ aws s3 --endpoint-url "$R2_ENDPOINT" rm "${S3_BUCKET}/${KEY}" \
414
+ && log "Deleted remote: ${KEY}" \
415
+ || log "WARN: failed to delete remote ${KEY}"
416
+ done
417
+ fi
418
+ ```
419
+
420
+ **3. VPS prerequisites.** Add `awscli` and `cron` to `cloud-init` `packages:` so future server replacements have them. Configure R2 credentials in `/root/.aws/credentials` (same keys as the Terraform state backend — they're account-scoped):
421
+
422
+ ```ini
423
+ [default]
424
+ aws_access_key_id = <R2 access key>
425
+ aws_secret_access_key = <R2 secret>
426
+ ```
427
+
428
+ **4. Cron entry** (`/etc/cron.d/<your-app>-pg-backup`). Wrap with `flock -n` so a hung run doesn't get a second instance started 24h later:
429
+
430
+ ```
431
+ 0 2 * * * root /usr/bin/flock -n /var/lock/<your-app>-pg-backup.lock /usr/local/bin/pg_backup_s3.sh >> /var/log/<your-app>_pg_backup.log 2>&1
432
+ ```
433
+
434
+ **5. Restore — `scripts/db-restore.sh`** (run from local). Lists server-side backups via SSH, prompts for selection, takes a safety dump first (with size check — refuses to proceed if pg_dump silently produced an empty file), then restores. Note `set -euo pipefail` inside the SSH heredocs (without it, a `pg_dump` failure inside a pipeline is masked by a successful `gzip`) and the `OVERWRITE <db>` confirmation phrase (a bare DB name is too easy to typo into):
435
+
436
+ ```bash
437
+ #!/usr/bin/env bash
438
+ set -euo pipefail
439
+ SSH_ALIAS="<your-app>"
440
+ CONTAINER="<your-app>-postgres-1"
441
+ DB="<your-db>"
442
+ REMOTE_DIR="/var/backups/postgresql"
443
+ MIN_SAFETY_BYTES=1024
444
+
445
+ trap 'echo "Interrupted — DB may be inconsistent. Latest safety snapshot is in $SSH_ALIAS:$REMOTE_DIR."' INT TERM
446
+
447
+ ssh -o ConnectTimeout=5 "$SSH_ALIAS" "docker exec $CONTAINER pg_isready -U postgres -d $DB" >/dev/null \
448
+ || { echo "Postgres not ready"; exit 1; }
449
+
450
+ mapfile -t BACKUPS < <(ssh "$SSH_ALIAS" "ls -1t $REMOTE_DIR/<your-app>_*.sql.gz 2>/dev/null | xargs -n1 basename")
451
+ [[ ${#BACKUPS[@]} -gt 0 ]] || { echo "No backups found"; exit 1; }
452
+ for i in "${!BACKUPS[@]}"; do printf " %2d) %s\n" "$((i+1))" "${BACKUPS[$i]}"; done
453
+ read -rp "Pick: " PICK
454
+ [[ "$PICK" =~ ^[0-9]+$ ]] && (( PICK >= 1 && PICK <= ${#BACKUPS[@]} )) || { echo "Invalid"; exit 1; }
455
+ CHOSEN="${BACKUPS[$((PICK-1))]}"
456
+
457
+ read -rp "Type 'OVERWRITE $DB' to confirm: " CONFIRM
458
+ [[ "$CONFIRM" == "OVERWRITE $DB" ]] || { echo "aborted"; exit 1; }
459
+
460
+ SAFETY="<your-app>_pre_restore_$(date +%Y-%m-%d_%H%M%S).sql.gz"
461
+ ssh "$SSH_ALIAS" "set -euo pipefail; docker exec $CONTAINER pg_dump -U postgres $DB | gzip > $REMOTE_DIR/$SAFETY"
462
+ SIZE=$(ssh "$SSH_ALIAS" "stat -c%s $REMOTE_DIR/$SAFETY")
463
+ (( SIZE >= MIN_SAFETY_BYTES )) || { echo "Safety snapshot suspiciously small ($SIZE B) — aborting"; exit 1; }
464
+
465
+ ssh "$SSH_ALIAS" "
466
+ set -euo pipefail
467
+ docker exec $CONTAINER psql -U postgres -d postgres -c \"DROP DATABASE IF EXISTS \\\"$DB\\\" WITH (FORCE);\"
468
+ docker exec $CONTAINER psql -U postgres -d postgres -c \"CREATE DATABASE \\\"$DB\\\";\"
469
+ gunzip -c $REMOTE_DIR/$CHOSEN | docker exec -i $CONTAINER psql -U postgres -d $DB
470
+ "
471
+ echo "Restored. Safety snapshot at $SSH_ALIAS:$REMOTE_DIR/$SAFETY."
472
+ ```
473
+
474
+ **6. Restore drill — quarterly.** A backup you've never restored from is not a backup. At least once a quarter, restore the latest dump into a throwaway local Postgres and verify schema + row counts. Calendar reminder.
475
+
476
+ ### Log archival (optional)
477
+
478
+ When to use it: you need long-term log retention beyond Docker's `json-file` rotation buffer (default ~150 MB rolling per container, configured in compose).
479
+
480
+ Pattern: a Hangfire recurring job watches `/app/logs/`, ships files older than N days to R2 once total > threshold, deletes locally.
481
+
482
+ To make `/app/logs` writable under `USER app`:
483
+
484
+ - **Bind mount with chowned host dir** (recommended): `volumes: - /var/log/<your-app>:/app/logs` in compose, one-time `mkdir -p /var/log/<your-app> && chown 1654:1654 /var/log/<your-app>` on the VPS. Bind mounts respect host ownership; named volumes overlay the path with root-owned storage which `app` can't write to.
485
+ - **No File sink at all** (simpler): rely on Console + Docker rotation. Drop the `/app/logs` volume entirely. Right answer for greenfield deploys.
486
+
487
+ ## Pitfalls
488
+
489
+ - **Cookie domain across subdomains.** Set `CookieDomain` to `.<your-domain>` (leading dot) so cookies set by `api.<your-domain>` are accepted by `admin.<your-domain>`. `SameSite=Lax` is the right default for an admin SPA.
490
+ - **`ForwardLimit = 2`.** With Cloudflare → Caddy → backend, your forwarded headers cross two proxies. The Spiderly scaffold ships `appsettings.Production.json` with `ForwardLimit: 2` already set; if you sit behind Cloudflare, paste the current Cloudflare CIDR list into `TrustedProxyNetworks` (refresh from https://www.cloudflare.com/ips/ periodically — Cloudflare adds ranges occasionally):
491
+
492
+ ```json
493
+ {
494
+ "AppSettings": {
495
+ "Spiderly.Shared": {
496
+ "ForwardLimit": 2,
497
+ "TrustedProxyNetworks": [
498
+ "173.245.48.0/20",
499
+ "103.21.244.0/22",
500
+ "103.22.200.0/22",
501
+ "103.31.4.0/22",
502
+ "141.101.64.0/18",
503
+ "108.162.192.0/18",
504
+ "190.93.240.0/20",
505
+ "188.114.96.0/20",
506
+ "197.234.240.0/22",
507
+ "198.41.128.0/17",
508
+ "162.158.0.0/15",
509
+ "104.16.0.0/13",
510
+ "104.24.0.0/14",
511
+ "172.64.0.0/13",
512
+ "131.0.72.0/22",
513
+ "2400:cb00::/32",
514
+ "2606:4700::/32",
515
+ "2803:f800::/32",
516
+ "2405:b500::/32",
517
+ "2405:8100::/32",
518
+ "2a06:98c0::/29",
519
+ "2c0f:f248::/32"
520
+ ]
521
+ }
522
+ }
523
+ }
524
+ ```
525
+
526
+ When `TrustedProxyNetworks` is unset, Spiderly trusts RFC 1918 private ranges by default — fine for Docker-internal Caddy → backend traffic but **not** for the outermost Cloudflare → Caddy hop, which arrives over public IPs.
527
+
528
+ On the Terraform side (Hetzner firewall, etc.), use the **`cloudflare_ip_ranges` data source** instead of a hardcoded list — keeps the firewall in sync with Cloudflare's published ranges automatically:
529
+
530
+ ```hcl
531
+ data "cloudflare_ip_ranges" "this" {}
532
+
533
+ resource "hcloud_firewall" "main" {
534
+ rule {
535
+ direction = "in"
536
+ protocol = "tcp"
537
+ port = "443"
538
+ source_ips = concat(data.cloudflare_ip_ranges.this.ipv4_cidrs, data.cloudflare_ip_ranges.this.ipv6_cidrs)
539
+ }
540
+ # ...
541
+ }
542
+ ```
543
+
544
+ The .NET `TrustedProxyNetworks` list still has to be hardcoded — there's no equivalent runtime data source short of fetching at startup. The hardcoded list and the Terraform data source describe the same set, so both rot at the same speed; pin a calendar reminder to refresh quarterly.
545
+ - **CORS `FrontendUrl`.** The backend's `AppSettings__Spiderly.Shared__FrontendUrl` must point to the admin subdomain, not a build-preview URL. Update it when you cut over from staging hosting.
546
+ - **First deploy ordering.** The backend compose references `${ADMIN_IMAGE}`. On a fresh VPS, that image must exist in GHCR before backend deploys, or the admin service definition fails to pull. Push admin first, or run the admin workflow once before the first backend deploy.
547
+ - **`docker compose up -d` scoping.** Always scope to the services you're updating: `docker compose up -d backend caddy` for backend deploys, `docker compose up -d admin` for admin deploys. Unscoped `up -d` bounces every service that's currently down or has a config change — including admin and postgres on a backend-only commit. The healthcheck-gated `depends_on` makes the unscoped form *safe* but not *desirable*.
548
+ - **Static assets caching.** Hashed Angular bundles (e.g. `main.abc123.js`) can be cached forever; `index.html` must never cache, or users will get a stale shell after a deploy. The Caddyfile in this skill already handles both cases.
549
+ - **`tls_private_key` lives in Terraform state in cleartext.** R2 encryption-at-rest is bucket-level; anyone with R2 API access (or a leaked state file) can read the key. Scope the R2 token tightly, audit who can pull state, and never copy `terraform.tfstate` to laptops or shared drives. Rotating the cert means a fresh `terraform apply` followed by re-pasting the new outputs into GitHub Secrets — plan a maintenance window. Add `lifecycle { prevent_destroy = true }` to the `tls_private_key` and `cloudflare_origin_ca_certificate` resources so accidental config changes can't trigger a silent rotation. Also add `ignore_changes = [hostnames, csr]` on the cert — Cloudflare normalizes hostname/CSR ordering server-side, otherwise every plan would show a phantom recreate.
550
+ - **Postgres data on Docker named volumes is not durable across server replacement.** If the VPS itself is Terraform-managed (`hcloud_server`) and gets recreated for any reason — image change, server_type change, accidental destroy — the local Docker named volume `postgres_data` goes with it. Add `lifecycle { prevent_destroy = true }` to the `hcloud_server` resource. For genuine durability, attach an `hcloud_volume` and bind-mount it into postgres (`/var/lib/postgresql` on the 18+ image); volumes survive server destruction and snapshot independently.
551
+ - **Cloudflare in front of Vercel must stay "DNS only".** When the Next.js storefront (or a Vercel-hosted admin) deploys to Vercel and DNS is on Cloudflare, those records must be **DNS only** (grey cloud), not proxied. Vercel terminates TLS and runs its own edge — CDN, image optimization, ISR, PPR — so adding the Cloudflare proxy on top breaks the TLS handshake and double-caches/conflicts with Vercel's edge features. Cloudflare's WAF/cache/analytics/Turnstile belong on the **Hetzner-served** records (`api.*`, `admin.*`) where the orange cloud stays on. If you want Cloudflare features in front of Vercel anyway, that's an advanced setup ("Full (strict)" SSL + custom hostnames) that Vercel does not officially recommend.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: ef-migrations
3
+ description: Create and apply EF Core migrations in Spiderly projects. Use when adding, modifying, or removing entity properties, changing column types, renaming columns, or any database schema change that requires a migration.
4
+ ---
5
+
6
+ # EF Migrations
7
+
8
+ ## Creating a migration
9
+
10
+ Run from the Backend directory, using the **Migrations console project** as startup (avoids DLL locking — no need to stop the running backend):
11
+
12
+ ```bash
13
+ dotnet ef migrations add <MigrationName> --project <InfrastructureProject> --startup-project <MigrationsProject>
14
+ ```
15
+
16
+ Example:
17
+
18
+ ```bash
19
+ cd MyApp/Backend
20
+ dotnet ef migrations add AddOrderNumberToProduct --project MyApp.Infrastructure --startup-project MyApp.Migrations
21
+ ```
22
+
23
+ Always review the generated migration file before proceeding.
24
+
25
+ ## Relationship schema is declarative — let the migration reflect it
26
+
27
+ Schema produced by relationship attributes (`[WithMany]` FKs, `[M2M]` join tables, `[WithOne]` one-to-one) is configured in the model (`OnModelCreating` extensions), so the migration just **reflects** it — you don't hand-write that DDL. In particular, a **one-to-one (`[WithOne]`) auto-emits a unique index on the FK**: do **not** add `[Index(IsUnique = true)]` yourself, and don't hand-write a `CREATE UNIQUE INDEX`. For an *optional* 1-1 (nullable FK) the index must allow many NULLs — that's left to provider conventions (Postgres `NULLS DISTINCT`, SQL Server's auto `IS NOT NULL` filter), so never add a `HasFilter`. If the generated migration shows a filtered/NULL-collapsing unique index on a nullable 1-1 FK, something is wrong — investigate rather than editing the migration by hand.
28
+
29
+ ## Applying locally
30
+
31
+ After creating a migration, always apply it to your local database:
32
+
33
+ ```bash
34
+ dotnet ef database update --project <InfrastructureProject> --startup-project <MigrationsProject>
35
+ ```
36
+
37
+ ## Data migrations
38
+
39
+ When a schema change requires data conversion (e.g., converting 0 to NULL after making a column nullable), add `migrationBuilder.Sql()` calls inside the `Up` method. Keep them simple and idempotent.
40
+
41
+ ## Production
42
+
43
+ Schema changes are applied to production **automatically through the deployment pipeline**. Never run DDL (ALTER TABLE, DROP COLUMN, CREATE INDEX, etc.) directly against the production database.
44
+
45
+ Direct production DB access (via db-query or psql) is only for **data queries and data updates** (SELECT, INSERT, UPDATE of row values) — not for schema changes.
46
+
47
+ ## The Migrations project
48
+
49
+ Each Spiderly solution includes a lightweight console app (e.g., `MyApp.Migrations`) specifically for running EF tooling. It exists because the main WebAPI project's DLLs are often locked by the running dev server. Always use it as the `--startup-project` for `dotnet ef` commands.