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

12 KiB

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

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.

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:

[
  {
    "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

  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 against STAGING_URL.

Production (manual)

.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:

# 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:

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):

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:

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

# 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

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

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.