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>
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 |
Resend (MAIL_MAILER=resend) |
|
| Proxy | Dokploy Traefik → service web port 8000 |
Prerequisites (manual)
- Gitea repository
manoel-freitas/amareathttps://git.hellomanoel.com; Repository Actions enabled in repo settings; a registered Gitea Actions runner (see docs.gitea.com usage/actions/quickstart) with anubuntu-latestlabel. - Dokploy installed on the VPS.
- 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. - Two PostgreSQL services in Dokploy (staging + production), private (no public port).
- Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
- 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.
- Waits for workflow
CIsuccess (workflow_run) on push tomain. - Builds once; pushes
:<full-sha>and:stagingto the Gitea registry. - Calls Dokploy
compose.deployand polls until done. - Runs
scripts/deploy/smoke.shagainstSTAGING_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
- Operator runs Gitea → Actions → Promote production.
- Inputs: full
shaalready in the Gitea registry;confirmmust be exactlyPRODUCTION. - Retags the same digest as
:production(no rebuild). - 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-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)
- Settings → Destinations: add S3-compatible destination (AWS S3, R2, etc.).
- Open each PostgreSQL service → Backup:
- Destination: the S3 destination
- Schedule: cron e.g.
0 3 * * * - Prefix:
amare/stagingoramare/production - Enabled: on
- Click Test and verify the object appears in the bucket.
- Prefer Dokploy alerts/webhooks for backup failure if configured.
Restore (staging rehearsal before first production promote)
- Create a scratch database or restore into a disposable Postgres service.
- Database → Backup → Restore: pick destination + backup file + target database name.
- Point a temporary compose env at the restored DB and confirm
/up+/admin/login. - 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_URLmatches public HTTPS originSESSION_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.