spiderly 19.8.4 → 19.8.6
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.
- package/agent/docs/angular-customization/SKILL.md +389 -0
- package/agent/docs/angular-customization/references/controls.generated.md +23 -0
- package/agent/docs/angular-customization/references/helper-functions.generated.md +39 -0
- package/agent/docs/angular-customization/references/ui-control-types.generated.md +24 -0
- package/agent/docs/angular-customization/references/validators.generated.md +13 -0
- package/agent/docs/authorization/SKILL.md +385 -0
- package/agent/docs/authorization/references/api-error-codes.generated.md +17 -0
- package/agent/docs/authorization/references/security-endpoints.generated.md +24 -0
- package/agent/docs/backend-hooks/SKILL.md +231 -0
- package/agent/docs/backend-localization/SKILL.md +170 -0
- package/agent/docs/backend-testing/SKILL.md +65 -0
- package/agent/docs/custom-endpoints/SKILL.md +409 -0
- package/agent/docs/e2e-testing/SKILL.md +139 -0
- package/agent/docs/entity-design/SKILL.md +346 -0
- package/agent/docs/entity-design/references/attributes.generated.md +53 -0
- package/agent/docs/file-storage/SKILL.md +262 -0
- package/agent/docs/filtering-patterns/SKILL.md +127 -0
- package/agent/docs/filtering-patterns/references/match-mode-codes.generated.md +15 -0
- package/agent/docs/frontend-localization/SKILL.md +120 -0
- package/agent/docs/mapper-customization/SKILL.md +105 -0
- package/agent/manifest.json +34 -0
- package/agent/skills/add-entity/SKILL.md +158 -0
- package/agent/skills/deployment/SKILL.md +551 -0
- package/agent/skills/ef-migrations/SKILL.md +49 -0
- package/agent/skills/report-gap/SKILL.md +110 -0
- package/agent/skills/report-gap/scripts/build-issue-url.mjs +82 -0
- package/agent/skills/spiderly-upgrade/SKILL.md +166 -0
- package/agent/skills/verify-ui/SKILL.md +148 -0
- package/agent/skills/verify-ui/scripts/get-admin-token.mjs +134 -0
- package/fesm2022/spiderly.mjs +11 -6
- package/fesm2022/spiderly.mjs.map +1 -1
- package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +29 -3
- 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.
|