Files
amare/docs/deployment/dokploy.md
manoel freitas 26a68e1823
All checks were successful
CI / static (push) Successful in 2m38s
CI / unit (push) Successful in 3m40s
CI / feature (push) Successful in 2m55s
CI / container (push) Successful in 1m11s
CI / browser (push) Successful in 4m22s
fix: restaurar deploy-staging via workflow_run (Gitea ≥1.25)
Remove job/reusable workflow do CI. Staging volta a ser workflow
separado após CI; 1.24 não implementava o trigger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:01:56 -03:00

16 KiB

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 :<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 git.hellomanoel.com/manoel-freitas/amare:<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. 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) 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); 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:

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.

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

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); 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/.

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 — 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 :<full-sha> and :staging to the Gitea registry.
  3. Calls Dokploy compose.deploy and polls until done.
  4. Runs 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

  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:

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

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

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