# 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 : + :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//:` (+ 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// 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= # 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://.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 `` 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 `:` 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//:staging \ ghcr.io//: # 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// 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.