Files
amare/docs/deployment/dokploy.md
Manoel Freitas 27711ad0c2 feat: add production testimonials seeder (#11)
Canonical upsert for five authorized couples so staging/prod
can load depoimentos without DatabaseSeeder/ContentSeeder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:11:33 -03:00

301 lines
12 KiB
Markdown

# Deploy Dokploy (staging → production)
Runbook for operating Amare on a VPS with Dokploy connected to GitHub, publishing immutable images to GHCR.
## Architecture
```
CI (main) → build FrankenPHP image → GHCR :<sha> + :staging
→ Dokploy staging compose.deploy
→ smoke /up / /admin/login
Promote (manual) → retag same digest as :production (no rebuild)
→ Dokploy production compose.deploy
→ smoke
```
| Piece | Detail |
|---|---|
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) |
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
| Mail | Resend (`MAIL_MAILER=resend`) |
| Proxy | Dokploy Traefik → service `web` port `8000` |
## Prerequisites (manual)
1. Dokploy installed on the VPS; GitHub provider connected.
2. GHCR registry in Dokploy (`ghcr.io`) with a PAT that can **read** packages (`read:packages`). Prefer a dedicated bot/token; do not store write tokens on the VPS.
3. Two PostgreSQL services in Dokploy (staging + production), private (no public port).
4. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
5. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
## Create Compose stacks
Create **two** Dokploy Compose services (same repo, same compose path):
| Stack | Compose path | `IMAGE_TAG` | Notes |
|---|---|---|---|
| staging | `docker-compose.deploy.yml` | `staging` | Auto-deployed after CI on `main` |
| production | `docker-compose.deploy.yml` | `production` | Manual promotion only |
Dokploy Environment for each stack must set:
```bash
APP_IMAGE=ghcr.io/<owner>/<repo>
IMAGE_TAG=staging # or production
```
Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish PostgreSQL or host ports for app processes.
Compose services must join the external Docker network `dokploy-network` (declared in `docker-compose.deploy.yml`) so they can resolve the Dokploy-managed Postgres internal host (e.g. `amare-stg-pez43e`). Set `DB_HOST` to that **Internal Host** from the Dokploy database UI — not a public hostname.
Source can be GitHub (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer GitHub + fixed compose path so updates stay in sync with `main`.
## Required Laravel env (Dokploy only)
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in GitHub Actions secrets or image layers.
```env
APP_NAME=Amare
APP_ENV=staging # or production
APP_KEY=base64:... # unique per environment — generate with php artisan key:generate --show
APP_DEBUG=false
APP_URL=https://staging.example.com
APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=pt_BR
APP_TIMEZONE=America/Fortaleza
DB_CONNECTION=pgsql
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
DB_PORT=5432
DB_DATABASE=amare_staging
DB_USERNAME=...
DB_PASSWORD=...
SESSION_DRIVER=database
SESSION_SECURE_COOKIE=true
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
CACHE_STORE=database
QUEUE_CONNECTION=database
FILESYSTEM_DISK=r2
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
R2_URL=https://media-staging.example.com
MAIL_MAILER=resend
RESEND_API_KEY=...
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME=Amare
LOG_LEVEL=warning
```
Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs.
Livewire temporary uploads default to the **local** disk in `AppServiceProvider` (even when `FILESYSTEM_DISK=r2`), so Filament does not browser-PUT to R2. Final media still lands on R2 via `PublicImageUploadRules`. Optional override: `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` in Dokploy `.env` (`env_file` accepts any key — does not need a compose `environment:` entry).
### R2 CORS (public/media reads from JS)
Upload path does not need R2 CORS with the local temp-disk default. Still useful if the browser fetches R2 URLs cross-origin from JS. Origin must match `APP_URL` exactly; include `AllowedHeaders`:
```json
[
{
"AllowedOrigins": ["https://hellomanoel.com"],
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Type"],
"MaxAgeSeconds": 3600
}
]
```
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
## GitHub Actions secrets
Repository secrets used by workflows:
| Secret | Purpose |
|---|---|
| `DOKPLOY_URL` | Panel origin **without** `/api` (e.g. `https://panel.example.com`). Do not use the OpenAPI base URL that ends in `/api` — that yields `/api/api/...` and 404s. |
| `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI |
| `DOKPLOY_STAGING_COMPOSE_ID` | Staging **Compose** service id (not an Application id) |
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
| `PRODUCTION_URL` | Public origin for production smoke |
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline.
## Workflows
### Staging (automatic)
[`.github/workflows/deploy-staging.yml`](../../.github/workflows/deploy-staging.yml)
1. Waits for workflow `CI` success on push to `main`.
2. Builds once; pushes `:<full-sha>` and `:staging`.
3. Calls Dokploy `compose.deploy` and polls until done.
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
### Production (manual)
[`.github/workflows/promote-production.yml`](../../.github/workflows/promote-production.yml)
1. Operator runs **Actions → Promote production**.
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
3. Retags the **same digest** as `:production` (no rebuild).
4. Deploys production compose + smoke.
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
## First admin and authorized production seeding
Seed credentials are local-only. For staging/production:
FrankenPHP sets `XDG_CONFIG_HOME=/config` (Caddy). PsySH/tinker then tries `/config/psysh`, which `appuser` cannot write — you get `Writing to directory /config/psysh is not allowed.` Override that env for the one-shot command:
```bash
# From Dokploy → staging/production → Open terminal on `web` (or one-off run)
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
\$user = \\App\\Models\\User::query()->updateOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => 'use-a-strong-password',
'role' => 'admin',
'is_active' => true,
]
);
\$user->forceFill(['email_verified_at' => now()])->save();
echo \$user->email.PHP_EOL;
"
```
Plain password is enough: `User` casts `password` to `hashed` (and skips re-hash when value already hashed). `email_verified_at` is not mass-assignable — use `forceFill` as above.
Confirm:
```bash
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::query()->where('email', 'admin@example.com')->exists() ? 'ok' : 'missing';"
```
Never reuse `admin@amare.local` / `password`.
### Load authorized testimonials after migrations
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
```bash
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
```
This seeder is safe to rerun: it overwrites canonical source-owned fields, preserves curated photo fields, and leaves unrelated testimonials unchanged. The five records are published with the approved deterministic timestamp. Upsert keys on `author_name` (no unique DB constraint); keep one row per couple before/after running.
Optional verification:
```bash
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
\$expected = collect(['Jeniffer e Maick', 'Quesia e Jhonata', 'Milena e Weslley', 'Raquel e Pedro', 'Victoria e Pedro']);
\$rows = \\App\\Models\\Testimonial::query()->whereIn('author_name', \$expected)->get(['author_name', 'published_at'])->groupBy('author_name');
\$valid = \$expected->every(function (string \$author) use (\$rows): bool {
\$matches = \$rows->get(\$author, collect());
return \$matches->count() === 1
&& \$matches->first()->published_at?->format('Y-m-d H:i:s') === '2026-08-05 00:00:00';
});
echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
"
```
Expected output: `ok`.
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
## Backup and restore
Policy (SPEC §16.3): daily PostgreSQL backup, retention ≥ 14 days, RPO ≤ 24h, RTO ≤ 4h.
### Configure (Dokploy)
1. Settings → Destinations: add S3-compatible destination (AWS S3, R2, etc.).
2. Open each PostgreSQL service → Backup:
- Destination: the S3 destination
- Schedule: cron e.g. `0 3 * * *`
- Prefix: `amare/staging` or `amare/production`
- Enabled: on
3. Click **Test** and verify the object appears in the bucket.
4. Prefer Dokploy alerts/webhooks for backup failure if configured.
### Restore (staging rehearsal before first production promote)
1. Create a scratch database or restore into a disposable Postgres service.
2. Database → Backup → **Restore**: pick destination + backup file + target database name.
3. Point a temporary compose env at the restored DB and confirm `/up` + `/admin/login`.
4. Document the timestamp of the successful rehearsal.
Do **not** promote to production until staging restore has been proven once.
## Rollback
No rebuild. Move the environment alias to a previous SHA digest and redeploy.
### Staging
```bash
# Locally or in a one-off Actions shell with GHCR login
docker buildx imagetools create \
--tag ghcr.io/<owner>/<repo>:staging \
ghcr.io/<owner>/<repo>:<previous-sha>
# Then trigger Dokploy deploy (UI Deploy, or):
DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \
./scripts/deploy/dokploy-deploy.sh
SMOKE_BASE_URL=https://staging.example.com ./scripts/deploy/smoke.sh
```
### Production
Same pattern with `:production` tag and production compose id / URL. Prefer re-running **Promote production** with the previous SHA and confirmation `PRODUCTION`.
If a migration is not backward-compatible, fix forward with a new SHA; keep migrations reversible when possible.
## Smoke checks
```bash
SMOKE_BASE_URL=https://staging.example.com ./scripts/deploy/smoke.sh
```
Expects HTTP 200 for `/up`, `/`, and `/admin/login`.
## Domain checklist before production promote
- [ ] Final hostname DNS → VPS
- [ ] Dokploy TLS certificate issued
- [ ] `APP_URL` matches public HTTPS origin
- [ ] `SESSION_SECURE_COOKIE=true`
- [ ] Staging smoke green on the SHA to promote
- [ ] Staging backup + restore rehearsed
- [ ] Production Postgres backup schedule enabled
- [ ] Production R2 bucket + Resend domain ready
- [ ] First admin created without seed
## Local validation of Compose
```bash
APP_IMAGE=ghcr.io/<owner>/<repo> IMAGE_TAG=staging \
docker compose -f docker-compose.deploy.yml config
```
Requires a `.env` file present (Dokploy creates it from Environment UI). For local config checks, an empty `.env` is enough.