Deploy Webapp
Production deploy: Caddy TLS + Compose on a specific domain and server.
Read first: ~/.cursor/skills/webapp-shared/reference.md
Prod routing: Caddy /api* → api:8080; / → web:80. Only Caddy publishes 80/443.
Gather inputs (required)
| Input | Example |
|---|---|
| App directory on server | /opt/demo-app |
DOMAIN |
app.example.com |
ACME_EMAIL |
admin@example.com |
| SSH target | deploy@203.0.113.10 |
| Sync method | git pull / rsync |
Use the user's actual DOMAIN and SSH host in every command — not placeholders alone.
Checklist
Deploy-webapp progress:
- [ ] 1. DNS A/AAAA → server IP
- [ ] 2. Server: Docker, Compose, ports 80/443 open
- [ ] 3. .env.prod from .env.prod.example (never commit)
- [ ] 4. Sync code to server
- [ ] 5. Explicit EF database update (prod)
- [ ] 6. compose prod up -d --build
- [ ] 7. Verify HTTPS, /api/health, login/refresh
- [ ] 8. Share logs + rollback steps with user
Step 1: DNS
| Record | Value |
|---|---|
| A (or @) | Server public IPv4 |
| AAAA (optional) | Server IPv6 |
dig +short <DOMAIN> # must match server IP before starting Caddy
Do not start Caddy until DNS resolves — ACME needs correct DOMAIN.
Step 2: Server prerequisites
ssh <user>@<host>
docker --version && docker compose version
sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
mkdir -p <app-dir>
Step 3: .env.prod
On server:
cd <app-dir> && cp .env.prod.example .env.prod
Set for user's DOMAIN:
DOMAIN,ACME_EMAIL- Strong
POSTGRES_PASSWORD+ matchingConnectionStrings__Default Jwt__SigningKeyfrom./scripts/generate-jwt-key.shCors__Origins=https://<DOMAIN>(no trailing slash)ASPNETCORE_ENVIRONMENT=Production
Full variable list: reference.md. Never commit .env.prod.
Step 4: Sync code
# Git
ssh <user>@<host> 'cd <app-dir> && git pull'
# Rsync
rsync -avz --exclude node_modules --exclude bin --exclude obj \
./ <user>@<host>:<app-dir>/
Required on server: docker-compose.prod.yml, Caddyfile, backend/, frontend/.
Step 5: Production migrations
Prod never auto-migrates on API startup.
ssh <user>@<host> 'cd <app-dir> && \
docker compose -f docker-compose.prod.yml --env-file .env.prod run --rm api \
dotnet ef database update \
--project src/{App}.Infrastructure \
--startup-project src/{App}.Api'
Step 6: Start stack
ssh <user>@<host> 'cd <app-dir> && \
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build'
# Pre-flight (on server)
docker compose -f docker-compose.prod.yml --env-file .env.prod config
Services: caddy, api, web, db — only caddy on host 80/443.
Step 7: Verify
curl -sf https://<DOMAIN>/api/health
curl -sf -X POST https://<DOMAIN>/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"prod@test.local","password":"Password1!"}'
curl -sf -X POST https://<DOMAIN>/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"prod@test.local","password":"Password1!"}'
# Test refresh + /api/me with returned tokens
Browser: https://<DOMAIN> loads SPA; login works; API calls same-origin /api/*.
Check Caddy logs on first deploy for certificate issuance.
Step 8: Logs and rollback
Logs:
ssh <user>@<host> 'cd <app-dir> && \
docker compose -f docker-compose.prod.yml --env-file .env.prod logs -f caddy api web db'
Rollback:
ssh <user>@<host> 'cd <app-dir> && \
docker compose -f docker-compose.prod.yml --env-file .env.prod down'
# git checkout previous release, then up -d --build again
DB schema rollback: restore Postgres backup (migrations are forward-only). TLS renewal: automatic via Caddy if 80/443 and DNS stay correct.
Troubleshooting
| Issue | Fix |
|---|---|
| ACME failure | DNS, port 80, correct DOMAIN in .env.prod |
502 /api |
logs api; container health |
| CORS | Cors__Origins=https://<DOMAIN>; recreate api |
| SPA refresh 404 | Rebuild web (nginx SPA fallback) |
| DB auth | Align POSTGRES_* and connection string |
Do not
- Auto-migrate in Production or publish api/web/db host ports
- Deploy before DNS points at server
- Commit
.env.prod
Related skills
- Local dev →
dev-webapp· Migrations →add-migration· Scaffold →create-webapp