Canonical upsert for five authorized couples so staging/prod can load depoimentos without DatabaseSeeder/ContentSeeder. Co-authored-by: Cursor <cursoragent@cursor.com>
301 lines
12 KiB
Markdown
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.
|