# Deploy Dokploy (staging → production) Runbook for operating Amare on a VPS with Dokploy connected to Gitea (git.hellomanoel.com), publishing immutable images to Gitea's container registry. ## Architecture ``` CI (main) → build FrankenPHP image → git.hellomanoel.com registry : + :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 | `git.hellomanoel.com/manoel-freitas/amare:` (+ 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. Gitea repository `manoel-freitas/amare` at `https://git.hellomanoel.com`; **Repository Actions enabled** in repo settings; a registered **Gitea Actions runner** (see [docs.gitea.com usage/actions/quickstart](https://docs.gitea.com/usage/actions/quickstart)) with an `ubuntu-latest` label. 2. Dokploy installed on the VPS. 3. Container registry in Dokploy (`git.hellomanoel.com`) with a PAT that can **read** packages (`read:package`). Prefer a dedicated bot/token; do not store write tokens on the VPS. The **write** PAT (`write:package`) lives only in Gitea repo secrets for the pipeline. 4. Two PostgreSQL services in Dokploy (staging + production), private (no public port). 5. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed. 6. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS. Note: Gitea's `GITEA_TOKEN` cannot push OCI packages ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication in the workflows uses a PAT (`REGISTRY_PAT`), not the token. Secret names must not use the reserved `GITEA_` prefix (Gitea rejects them as invalid). ## 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=git.hellomanoel.com/manoel-freitas/amare 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 Gitea (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer Gitea + 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 Gitea 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/Sao_Paulo 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. ## Gitea Actions secrets Repository secrets (Gitea → Settings → Actions → 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 | | `REGISTRY_PAT` | Personal Access Token with `read:package` + `write:package` scopes — used to push images to `git.hellomanoel.com` (do not name secrets `GITEA_*`; that prefix is reserved) | | `REGISTRY_USER` | Gitea username that owns `REGISTRY_PAT` (`manoel-freitas`) | HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`. The automatic `GITEA_TOKEN` runs workflows but **cannot push OCI packages** ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication therefore uses `REGISTRY_PAT` + `REGISTRY_USER`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in Gitea for this pipeline. ## Workflows Workflows live in [`.gitea/workflows/`](../../.gitea/workflows/). `actions/cache` and Docker Buildx `type=gha` are **not** used: the act_runner cache server is not reachable from job containers by default (`getCacheEntry` ETIMEDOUT). Re-enable only after configuring a reachable `cache.host`/`external_server` on the runner. CI Postgres services must **not** publish host port `5432` (use service hostname `postgres` on the job network). Publishing `5432:5432` on a shared VPS runner fails with `Bind for 0.0.0.0:5432 failed: port is already allocated` when another job/orphan still holds the port. Parallel CI jobs need the act_runner `config.yaml` to keep `container.network` **empty** (per-job Docker network + service DNS) and `runner.capacity` ≥ 2. Setting `network: bridge` puts every job on the default bridge and makes parallel Postgres collide. Nested app containers (browser/container jobs) must join that job network by name and must **not** publish host `:8000`. On the current 1 vCPU / ~4 GiB VPS, `capacity: 2` is the safe ceiling. ### Staging (automatic) [`.gitea/workflows/deploy-staging.yml`](../../.gitea/workflows/deploy-staging.yml) — separate workflow, triggered by `workflow_run` when **CI** completes on `main`. Requires **Gitea ≥ 1.25** (`workflow_run` is not implemented as an Actions trigger in 1.24.x). Match both workflow display name `CI` and file id `ci.yml`. 1. Waits for workflow `CI` success (`workflow_run`) on push to `main`. 2. Builds once; pushes `:` and `:staging` to the Gitea registry. 3. Calls Dokploy `compose.deploy` and polls until done. 4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`. Manual re-deploy: **Actions → Deploy staging → Run workflow** (`workflow_dispatch`). `DOKPLOY_API_KEY` must be the **plaintext** key from Dokploy → Profile → API (starts like `amare…`). Do not paste the hashed `apikey.key` column from Postgres — that yields HTTP 401. ### Production (manual) [`.gitea/workflows/promote-production.yml`](../../.gitea/workflows/promote-production.yml) 1. Operator runs **Gitea → Actions → Promote production**. 2. Inputs: full `sha` already in the Gitea registry; `confirm` must be exactly `PRODUCTION`. 3. Retags the **same digest** as `:production` (no rebuild). 4. Deploys production compose + smoke. Human approval is the explicit `workflow_dispatch` + confirmation string (Gitea does not support GitHub Environment required reviewers). ## 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`. The `migrate` service in `docker-compose.deploy.yml` runs `php artisan db:seed --class=ContentSeeder --force --no-interaction` on every deploy, in both stacks. `ContentSeeder::run()` guards itself with an **allow-list** — `App::environment(['local', 'staging', 'testing'])` — and returns immediately (exit code 0, no side effects) unless `APP_ENV` is exactly one of those three values. In **staging** (`APP_ENV=staging`) the guard matches, so `ContentSeeder` still seeds its demo content on every deploy — this is required for visual review and is expected behavior, not a bug. In **production** (`APP_ENV=production`), and for any blank, mistyped, or unexpectedly cased `APP_ENV` value in any stack, the guard does not match, so the step is a deliberate no-op: it never overwrites SiteSetting/Service/PortfolioCase records edited in Filament, never re-uploads fixture images to the production storage disk, and never auto-publishes the fictional portfolio cases. Note the tradeoff this implies: if staging's `APP_ENV` is ever typo'd away from exactly `staging`, demo content silently stops being (re)seeded there too — check the Dokploy environment value first if a staging deploy stops refreshing demo content. Do not run bare `DatabaseSeeder` in staging or production: it also creates the `admin@amare.local` / `password` local-dev credentials. Publishing the five real testimonials is a separate, deliberate, manually triggered step **only in production** — `ContentSeeder` calls `TestimonialsSeeder` internally, but that call is skipped in production by the same allow-list guard, so the command above is the only path that publishes testimonials there. In **staging**, this is not manual: because the allow-list guard matches `staging`, `ContentSeeder` calls `TestimonialsSeeder` automatically on every deploy, auto-publishing/re-publishing the five canonical testimonials each time (consistent with staging's role as a demo/preview 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 Gitea registry login docker buildx imagetools create \ --tag git.hellomanoel.com/manoel-freitas/amare:staging \ git.hellomanoel.com/manoel-freitas/amare: # 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=git.hellomanoel.com/manoel-freitas/amare 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.