# Deploy Webapp

> Deploys a dotnet-vite-webapp-template app to production using docker-compose.prod.yml and Caddy TLS on a specific domain and SSH server, including DNS, .env.prod secrets, migration apply, and verification. Use when the user invokes /deploy-webapp, asks to deploy/go live, or configure Caddy production hosting.

- Skill: `manifold-works/deploy-webapp` (Agent Skill)
- Install (CLI): `npx skillmds@latest add manifold-works/deploy-webapp`
- Raw SKILL.md: https://api.skillmd.com/api/skills/manifold-works/deploy-webapp/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: Manifold-Works (https://skillmd.com/u/manifold-works)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/manifold-works/deploy-webapp

---


# Deploy Webapp

Production deploy: Caddy TLS + Compose on a **specific domain and server**.

**Read first:** [`~/.cursor/skills/webapp-shared/reference.md`](../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 |

```bash
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

```bash
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:

```bash
cd <app-dir> && cp .env.prod.example .env.prod
```

Set for user's **`DOMAIN`**:

- `DOMAIN`, `ACME_EMAIL`
- Strong `POSTGRES_PASSWORD` + matching `ConnectionStrings__Default`
- `Jwt__SigningKey` from `./scripts/generate-jwt-key.sh`
- `Cors__Origins=https://<DOMAIN>` (no trailing slash)
- `ASPNETCORE_ENVIRONMENT=Production`

Full variable list: [`reference.md`](../webapp-shared/reference.md). Never commit `.env.prod`.

## Step 4: Sync code

```bash
# 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.

```bash
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

```bash
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

```bash
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:**

```bash
ssh <user>@<host> 'cd <app-dir> && \
  docker compose -f docker-compose.prod.yml --env-file .env.prod logs -f caddy api web db'
```

**Rollback:**

```bash
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`

