Compare commits

...

1 Commits

Author SHA1 Message Date
57bca19aff feat: prepare Dokploy staging and production deploy pipeline
Publish immutable FrankenPHP images to GHCR, auto-deploy staging after CI,
and promote the same digest to production with smoke, backup, and rollback docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 23:39:32 -03:00
15 changed files with 722 additions and 45 deletions

View File

@@ -2,6 +2,7 @@ APP_NAME=Amare
APP_ENV=local
APP_KEY=
APP_DEBUG=true
# Staging/production: set APP_URL to the public HTTPS origin (e.g. https://staging.example.com).
APP_URL=http://localhost
APP_LOCALE=pt_BR
@@ -38,6 +39,10 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Staging/production behind HTTPS (Dokploy Traefik): SESSION_SECURE_COOKIE=true
SESSION_SECURE_COOKIE=false
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local

82
.github/workflows/deploy-staging.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: Deploy staging
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
permissions:
contents: read
packages: write
concurrency:
group: deploy-staging
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
deploy:
name: publish-and-deploy-staging
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
steps:
- name: Checkout deployed SHA
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
- name: Set image metadata
id: meta
run: |
SHA="${{ github.event.workflow_run.head_sha }}"
SHORT_SHA="${SHA:0:7}"
IMAGE="${REGISTRY}/${IMAGE_NAME}"
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push SHA + staging tags
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
${{ steps.meta.outputs.image }}:staging
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy staging on Dokploy
env:
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_STAGING_COMPOSE_ID }}
DEPLOY_TITLE: "staging ${{ steps.meta.outputs.short_sha }}"
run: |
chmod +x scripts/deploy/dokploy-deploy.sh
./scripts/deploy/dokploy-deploy.sh
- name: Smoke staging
env:
SMOKE_BASE_URL: ${{ secrets.STAGING_URL }}
run: |
chmod +x scripts/deploy/smoke.sh
./scripts/deploy/smoke.sh

View File

@@ -0,0 +1,84 @@
name: Promote production
on:
workflow_dispatch:
inputs:
sha:
description: Full git SHA already published to GHCR (same digest used by staging)
required: true
type: string
confirm:
description: Type PRODUCTION to confirm promotion of the given SHA
required: true
type: string
permissions:
contents: read
packages: write
concurrency:
group: deploy-production
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
promote:
name: promote-production
runs-on: ubuntu-latest
steps:
- name: Guard confirmation
run: |
if [ "${{ inputs.confirm }}" != "PRODUCTION" ]; then
echo "Confirmation must be exactly PRODUCTION" >&2
exit 1
fi
- name: Checkout repository scripts
uses: actions/checkout@v4
- name: Set image metadata
id: meta
run: |
SHA="${{ inputs.sha }}"
SHORT_SHA="${SHA:0:7}"
IMAGE="${REGISTRY}/${IMAGE_NAME}"
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Point :production at existing SHA digest (no rebuild)
run: |
docker buildx imagetools create \
--tag "${{ steps.meta.outputs.image }}:production" \
"${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}"
- name: Deploy production on Dokploy
env:
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_PRODUCTION_COMPOSE_ID }}
DEPLOY_TITLE: "production ${{ steps.meta.outputs.short_sha }}"
run: |
chmod +x scripts/deploy/dokploy-deploy.sh
./scripts/deploy/dokploy-deploy.sh
- name: Smoke production
env:
SMOKE_BASE_URL: ${{ secrets.PRODUCTION_URL }}
run: |
chmod +x scripts/deploy/smoke.sh
./scripts/deploy/smoke.sh

View File

@@ -125,3 +125,10 @@ Após `php artisan db:seed`:
- [SPEC.md](SPEC.md) — especificação do produto
- [docs/adr/](docs/adr/) — ADRs aceitas
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
## Deploy (Dokploy)
Staging publica automaticamente após CI verde em `main` (imagem GHCR por SHA + alias `:staging`). Produção promove a **mesma digest** com workflow manual `Promote production` (sem rebuild).
Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md).

View File

@@ -12,7 +12,8 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
$middleware->trustProxies(at: '*');
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

59
docker-compose.deploy.yml Normal file
View File

@@ -0,0 +1,59 @@
# Shared Compose for Dokploy staging and production.
# Both stacks use the same file with different env:
# APP_IMAGE=ghcr.io/<owner>/<repo>
# IMAGE_TAG=staging|production|<git-sha>
# PostgreSQL is a separate Dokploy database service (not defined here).
# Traefik/Dokploy domains should target service `web` port 8000.
services:
migrate:
image: ${APP_IMAGE}:${IMAGE_TAG}
pull_policy: always
restart: "no"
env_file:
- .env
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
web:
image: ${APP_IMAGE}:${IMAGE_TAG}
pull_policy: always
restart: unless-stopped
env_file:
- .env
depends_on:
migrate:
condition: service_completed_successfully
expose:
- "8000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/up"]
interval: 30s
timeout: 5s
start_period: 40s
retries: 3
queue:
image: ${APP_IMAGE}:${IMAGE_TAG}
pull_policy: always
restart: unless-stopped
env_file:
- .env
depends_on:
migrate:
condition: service_completed_successfully
command: ["php", "artisan", "queue:work", "--sleep=2", "--tries=3", "--max-time=3600"]
stop_grace_period: 60s
stop_signal: SIGTERM
scheduler:
image: ${APP_IMAGE}:${IMAGE_TAG}
pull_policy: always
restart: unless-stopped
env_file:
- .env
depends_on:
migrate:
condition: service_completed_successfully
command: ["php", "artisan", "schedule:work"]
stop_grace_period: 30s
stop_signal: SIGTERM

View File

@@ -7,5 +7,8 @@ Mesma imagem, comandos distintos:
| web | `frankenphp run --config /etc/caddy/Caddyfile` |
| queue | `php artisan queue:work --sleep=2 --tries=3` |
| scheduler | `php artisan schedule:work` |
| migrate | `php artisan migrate --force` (one-shot no Compose de deploy) |
FrankenPHP em **modo regular** (ADR-006). Worker mode proibido no MVP.
Deploy Dokploy (staging/produção): ver [`docker-compose.deploy.yml`](../docker-compose.deploy.yml) e [docs/deployment/dokploy.md](../docs/deployment/dokploy.md).

236
docs/deployment/dokploy.md Normal file
View File

@@ -0,0 +1,236 @@
# 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`](../../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:
```bash
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.
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=<dokploy-postgres-host>
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.
## GitHub Actions secrets
Repository secrets used by workflows:
| Secret | Purpose |
|---|---|
| `DOKPLOY_URL` | Base URL of the Dokploy panel (no trailing slash) |
| `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI |
| `DOKPLOY_STAGING_COMPOSE_ID` | Staging compose id |
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production compose id |
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
| `PRODUCTION_URL` | Public origin for production smoke |
`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 `:<full-sha>` 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 (no `db:seed` in production)
Seed credentials are local-only. For staging/production:
```bash
# From Dokploy → staging/production → Open terminal on `web` (or one-off run)
php artisan tinker --execute="
\\App\\Models\\User::query()->updateOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => bcrypt('use-a-strong-password'),
'role' => 'admin',
'is_active' => true,
'email_verified_at' => now(),
]
);
"
```
Adjust field names if the model evolves. Never reuse `admin@amare.local` / `password`.
## 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/<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
```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/<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.

View File

@@ -15,15 +15,17 @@ Fases 01 e provedores de produção (Resend + R2) estão em `main` com CI ver
- Staging em Dokploy com a mesma imagem FrankenPHP por SHA para web, queue e scheduler.
- Deploy automático em `main` após CI: build → GHCR → Dokploy API → migrate → health → smoke.
- Compose local com app FrankenPHP + Postgres.
- PHP 8.4 canônico em docs/Docker/CI.
- Gates com `npm audit` e cobertura Domain/Application ≥ 80%.
- E-mail verificado + reset de senha seguros no painel (ADM-01 / SPEC §12.1).
- Strict types em PHP próprio faltante.
- Produção via promoção manual da mesma digest SHA (alias `:production`), sem rebuild.
- Backup PostgreSQL diário (retenção ≥14 dias), restore documentado e rollback por SHA.
- Compose local com app FrankenPHP + Postgres (pendente fora da fatia deploy).
- PHP 8.4 canônico em docs/Docker/CI (pendente).
- Gates com `npm audit` e cobertura Domain/Application ≥ 80% (pendente).
- E-mail verificado + reset de senha seguros no painel (ADM-01 / SPEC §12.1) (pendente).
- Strict types em PHP próprio faltante (pendente).
**Non-Goals:**
- Produção, promoção humana, provisionamento de VPS para clientes.
- Deploy automático em produção; provisionamento de VPS para clientes.
- Fase 2 (briefing/CRM/E2E-01/02).
- Redis, worker mode, CDN automation, signed private media.
@@ -31,11 +33,13 @@ Fases 01 e provedores de produção (Resend + R2) estão em `main` com CI ver
### D1 — Dokploy Compose na VPS, imagem imutável no GHCR
GitHub Actions (após CI verde em `main`) constrói **uma** imagem `ghcr.io/<owner>/<repo>:<git-sha>` (+ alias `:staging`), faz push privado e chama `POST /api/compose.deploy` (ou `compose.update` + `compose.deploy`) no Dokploy com `x-api-key`.
GitHub Actions (após CI verde em `main`) constrói **uma** imagem `ghcr.io/<owner>/<repo>:<git-sha>` (+ alias `:staging`), faz push privado e chama `POST /api/compose.deploy` no Dokploy com `x-api-key`.
Compose de staging referencia `${APP_IMAGE}` / `IMAGE_TAG` para `web`, `queue`, `scheduler` e job `migrate` one-shot (`php artisan migrate --force`). PostgreSQL é serviço Dokploy separado (não na imagem da app).
Compose compartilhado (`docker-compose.deploy.yml`) referencia `${APP_IMAGE}` / `IMAGE_TAG` para `web`, `queue`, `scheduler` e job `migrate` one-shot (`php artisan migrate --force`). Dokploy mantém **duas** stacks Compose (staging e production) com `IMAGE_TAG` distinto. PostgreSQL é serviço Dokploy separado por ambiente (não na imagem da app).
*Alternativas rejeitadas:* Railway (Pro para GHCR privado + desvio de plataforma); rebuild por serviço no Dokploy (quebra “mesma imagem” SPEC §14.3/§15.2); tag só `:latest` (rollback frágil).
Produção: `workflow_dispatch` com SHA + confirmação `PRODUCTION` move alias `:production` para o **mesmo digest** já publicado e dispara `compose.deploy` na stack de produção. Repo privado no GitHub Free não tem required reviewers de Environment; aprovação humana = disparo manual explícito (GitHub Pro opcional depois).
*Alternativas rejeitadas:* Railway (Pro para GHCR privado + desvio de plataforma); rebuild por serviço no Dokploy (quebra “mesma imagem” SPEC §14.3/§15.2); tag só `:latest` (rollback frágil); deploy automático direto em produção.
### D2 — Processos e health
@@ -50,7 +54,11 @@ Healthcheck Docker/Dokploy e smoke pós-deploy usam `GET /up` (sem auth, sem sec
### D3 — Secrets e providers
Secrets em GitHub Actions + env Dokploy: `APP_KEY`, DB, `MAIL_MAILER=resend` / `RESEND_API_KEY`, `FILESYSTEM_DISK=r2` / `R2_*`, tokens Dokploy/GHCR. Nunca embeds em layer. Local continua `MAIL_MAILER=log` e disco `public`.
GitHub Actions guarda só orquestração: `DOKPLOY_URL`, API key, compose IDs, URLs públicas de smoke. Env Laravel (`APP_KEY`, DB, Resend, R2) vive **somente** no Dokploy, isolado por ambiente. Nunca embeds em layer. Local continua `MAIL_MAILER=log` e disco `public`.
### D3b — Backup e restore
PostgreSQL staging/produção: backup diário via Dokploy → destino S3-compatible, retenção mínima 14 dias (SPEC §16.3). Restore documentado e testado em staging antes da primeira promoção a produção.
### D4 — Compose local com app
@@ -74,26 +82,32 @@ Alinhar README, CI (`setup-php` 8.4) e `Dockerfile` `ARG PHP_VERSION=8.4`. Mante
### D8 — Rollback
Rollback = apontar Compose staging para tag SHA anterior no GHCR e `compose.deploy`/`redeploy`. Falha de healthcheck impede promoção. Sem rebuild.
Rollback = mover alias do ambiente (`:staging` ou `:production`) para tag SHA anterior no GHCR e `compose.deploy`. Falha de healthcheck/smoke impede promoção. Sem rebuild.
### D9 — Trusted proxies atrás do Traefik
`bootstrap/app.php` confia em proxies (`trustProxies(at: '*')`) para honrar `X-Forwarded-*` do Traefik/Dokploy. Staging/produção usam `SESSION_SECURE_COOKIE=true` com HTTPS.
## Risks / Trade-offs
- **[GHCR privado + pull na VPS]** → configurar registry no Dokploy com PAT `read:packages`; documentar checklist.
- **[Migrate one-shot falha]** → deploy não promove web; manter migrations backward-compatible.
- **[Cobertura 80% com Domain quase vazio]** → medir só namespaces existentes; baseline sobe conforme Fase 2 adiciona Domain.
- **[npm audit ruido]** → `--omit=dev` + allowlist documentada se necessário; sem silenciar sem justificativa.
- **[E-mail verification em staging]** → seed/users de staging com `email_verified_at`; Resend para reset real quando configurado.
- **[Compose local rebuild lento]** → documentar serve opcional; CI permanece fonte FrankenPHP.
- **[Migrate one-shot falha]** → web/queue/scheduler dependem de migrate exit 0; manter migrations backward-compatible.
- **[GitHub Free sem required reviewers]** → promoção humana via `workflow_dispatch` + input `PRODUCTION`; Pro opcional.
- **[Cobertura 80% com Domain quase vazio]** → medir só namespaces existentes; baseline sobe conforme Fase 2 adiciona Domain (pendente).
- **[npm audit ruido]** → `--omit=dev` + allowlist documentada se necessário (pendente).
- **[E-mail verification em staging]** → seed/users de staging com `email_verified_at`; Resend para reset real quando configurado (pendente).
- **[Compose local rebuild lento]** → documentar serve opcional; CI permanece fonte FrankenPHP (pendente).
## Migration Plan
1. Implementar auth/coverage/npm/strict_types/docs/Compose local; CI verde.
2. Criar projeto Dokploy + Postgres + Compose app; registrar GHCR.
3. Adicionar workflow deploy; primeiro push de imagem SHA; smoke `/up` + home + login.
4. Atualizar SPEC §18 Fase 0 apenas com itens comprovados; evidência no PR.
5. Rollback: redeploy tag SHA anterior.
1. Fatia deploy: Compose deploy, workflows, smoke, trusted proxies, docs Dokploy/backup/rollback; CI verde.
2. Criar projeto Dokploy + Postgres (staging + produção) + Compose apps; registrar GHCR.
3. Primeiro push de imagem SHA → staging; smoke `/up` + home + login; testar rollback e backup/restore.
4. Promoção manual para produção após domínio/TLS/`APP_URL` confirmados.
5. Fatias restantes da change (auth/coverage/npm/Compose local/PHP docs) em PRs seguintes.
6. Atualizar SPEC §18 Fase 0 apenas com itens comprovados; evidência no PR.
## Open Questions
- Domínio público exato do staging (DNS) — preencher na implementação com valor do operador.
- Se Dokploy Compose API exigir `compose.saveEnvironment` para `IMAGE_TAG` a cada deploy: confirmar payload na primeira fatia de integração.
- Domínio público exato do staging/produção (DNS) — preencher na implementação com valor do operador.
- Se Dokploy Compose API exigir `compose.update` env para `IMAGE_TAG` a cada deploy: preferir aliases `:staging`/`:production` estáveis no Compose Dokploy para evitar rewrite de env.

View File

@@ -6,17 +6,20 @@ Fases 0 e 1 estão implementadas e mescladas, mas o critério de saída da Fase
- Implantar staging na VPS própria via Dokploy (Docker Compose): imagem única por SHA no GHCR, serviços `web`/`queue`/`scheduler`/migrate, PostgreSQL gerenciado, healthcheck `/up`, smoke pós-deploy e rollback por tag SHA anterior.
- Adicionar workflow GitHub Actions de deploy em `main` após CI verde (build → push GHCR → acionar API Dokploy).
- Estender `docker-compose.yml` local com serviço de aplicação FrankenPHP (além do PostgreSQL).
- Fixar PHP **8.4** como versão canônica em Docker, CI e documentação.
- Incluir `npm audit` e cobertura mínima de 80% para `Domain` e `Application` nos gates de qualidade (SPEC §12.6, §13.7, §13.9).
- Exigir e-mail verificado no painel Filament e entregar reset de senha seguro (SPEC §12.1; ADM-01).
- Corrigir `declare(strict_types=1);` em PHP próprio que ainda falte e cobrir regressões.
- Adicionar promoção manual de produção: mesma digest SHA já publicada, alias `:production`, sem rebuild (`workflow_dispatch` + confirmação explícita).
- Documentar backup PostgreSQL diário (retenção ≥14d), restore e runbook operacional Dokploy/GHCR.
- Estender `docker-compose.yml` local com serviço de aplicação FrankenPHP (além do PostgreSQL) — **ainda pendente** nesta fatia de deploy.
- Fixar PHP **8.4** como versão canônica em Docker, CI e documentação — **ainda pendente**.
- Incluir `npm audit` e cobertura mínima de 80% para `Domain` e `Application` nos gates de qualidade (SPEC §12.6, §13.7, §13.9) — **ainda pendente**.
- Exigir e-mail verificado no painel Filament e entregar reset de senha seguro (SPEC §12.1; ADM-01) — **ainda pendente**.
- Corrigir `declare(strict_types=1);` em PHP próprio que ainda falte e cobrir regressões — **ainda pendente**.
## Non-Goals
Conforme [SPEC.md §4.2](../../SPEC.md):
- Produção com promoção humana, portal do cliente, multi-tenancy, Redis, FrankenPHP worker mode.
- Deploy automático direto em produção (produção exige promoção humana da mesma imagem).
- Portal do cliente, multi-tenancy, Redis, FrankenPHP worker mode.
- Fase 2 (WEB-05 briefing, CRM, E2E-01/E2E-02) — change futura `build-leads-crm` após esta fechar.
- Provisionamento genérico de VPS/Dokploy para clientes finais.
- Templates de e-mail de lead, auditoria completa (ADM-02), documentos privados.
@@ -25,19 +28,20 @@ Conforme [SPEC.md §4.2](../../SPEC.md):
### New Capabilities
- `staging-deployment`: deploy automático de staging na VPS via Dokploy com imagem imutável por SHA, processos web/queue/scheduler, migração, healthcheck, smoke e rollback (SPEC §14.3, §15.2, §18 Fase 0).
- `staging-deployment`: deploy automático de staging na VPS via Dokploy com imagem imutável por SHA, processos web/queue/scheduler, migração, healthcheck, smoke e rollback; promoção manual da mesma digest para produção (SPEC §14.3, §15.2, §16.3, §18 Fase 0).
### Modified Capabilities
- `container-runtime`: Compose local com app FrankenPHP; PHP 8.4 canônico; alinhamento da mesma imagem a processos de staging.
- `container-runtime`: Compose local com app FrankenPHP; PHP 8.4 canônico; alinhamento da mesma imagem a processos de staging/produção.
- `quality-gates`: `npm audit` no gate; cobertura mínima 80% para Domain/Application; job de deploy staging após CI.
- `health-check`: healthcheck e smoke pós-deploy de staging usam `/up` sem autenticação.
- `internal-authentication`: e-mail verificado obrigatório para acesso ao painel; reset de senha seguro disponível (SPEC §12.1, ADM-01).
## Impact
- **Cria**: `docker-compose` de staging (ou extensão), workflow `.github/workflows/deploy-staging.yml`, docs operacionais de Dokploy/GHCR, testes de auth verification/reset e cobertura.
- **Altera**: `docker-compose.yml`, `Dockerfile`/docs PHP, `composer.json`/`package.json` scripts, `.github/workflows/ci.yml`, `User`/`AdminPanelProvider`, README, `.env.example`.
- **Infra (manual)**: projeto Dokploy na VPS, registry GHCR, secrets (`DOKPLOY_*`, `GHCR_*`, DB, `APP_KEY`, Resend/R2).
- **Cria (fatia deploy)**: `docker-compose.deploy.yml`, workflows `deploy-staging.yml` / `promote-production.yml`, scripts smoke/Dokploy, docs operacionais Dokploy/GHCR/backup/rollback.
- **Altera (fatia deploy)**: `bootstrap/app.php` (trusted proxies), `.env.example`, README.
- **Ainda pendente nesta change**: Compose local FrankenPHP, PHP 8.4 docs, npm audit/coverage, auth verification/reset, strict_types.
- **Infra (manual)**: projeto Dokploy na VPS (duas stacks), registry GHCR, secrets (`DOKPLOY_*`, DB, `APP_KEY`, Resend/R2), backup S3.
- **Depende de**: specs já arquivadas (`container-runtime`, `quality-gates`, `health-check`, `internal-authentication`, `transactional-email`, `object-storage`).
- **Risco**: secrets e registry privados; mitigações: tokens com escopo mínimo, imagem por SHA, healthcheck antes de promover, rollback por tag anterior.

View File

@@ -55,3 +55,23 @@ Rollback SHALL redeploy a previously published SHA-tagged image without rebuildi
- **WHEN** the operator points staging Compose at a previous SHA tag and redeploys
- **THEN** web, queue, and scheduler MUST run that previous image
- **AND** no source rebuild MUST be required
### Requirement: Production promotion reuses the same immutable digest
Production SHALL be promoted from an already-published SHA-tagged image without rebuilding from source. Promotion MUST require explicit human action (SPEC §14.3).
#### Scenario: Operator promotes a staging-approved SHA to production
- **WHEN** the operator confirms promotion of commit SHA `abc123`
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `ghcr.io/<owner>/<repo>:abc123`
- **AND** MUST NOT rebuild from source for that promotion
### Requirement: Database backups exist before production cutover
Staging and production PostgreSQL services SHALL have automated daily backups with retention of at least 14 days, and a documented restore procedure MUST be verified on staging before the first production promotion (SPEC §16.3).
#### Scenario: Staging restore is proven before production promotion
- **WHEN** the operator prepares the first production promotion
- **THEN** a restore from a staging backup MUST have been documented and successfully tested
- **AND** production MUST have daily backup configured with retention of at least 14 days

View File

@@ -19,23 +19,25 @@
- [ ] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
## 4. Staging Compose and Dokploy prep
## 4. Staging/production Compose and Dokploy prep
- [ ] 4.1 Add versioned staging Compose template (web, queue, scheduler, migrate one-shot) parameterized by `APP_IMAGE`/`IMAGE_TAG`
- [ ] 4.2 Document Dokploy project setup: GHCR registry credentials, Postgres service, Compose import, required env vars (APP_KEY, DB, Resend, R2)
- [ ] 4.3 Document rollback procedure: redeploy previous SHA tag without rebuild
- [x] 4.1 Add versioned Compose template (`docker-compose.deploy.yml`: web, queue, scheduler, migrate one-shot) parameterized by `APP_IMAGE`/`IMAGE_TAG` for staging and production stacks
- [x] 4.2 Document Dokploy project setup: GHCR registry credentials, Postgres per environment, Compose import, required env vars (APP_KEY, DB, Resend, R2), trusted proxies/session cookies
- [x] 4.3 Document rollback procedure: move environment alias to previous SHA and redeploy without rebuild
- [x] 4.4 Document PostgreSQL daily backup (≥14d retention), restore procedure, and test restore on staging before first production promotion
## 5. Deploy workflow and smoke
- [ ] 5.1 Create `.github/workflows/deploy-staging.yml` gated on successful CI on `main`: build image, push `ghcr.io/...:<sha>` + `:staging`, trigger Dokploy `compose.deploy`
- [ ] 5.2 Wire migrate-before-serve (Compose migrate service or Dokploy deploy command) and healthcheck on `/up`
- [ ] 5.3 Add post-deploy smoke script/job for `/up`, `/`, `/admin/login` returning 200
- [ ] 5.4 Store secrets only in GitHub/Dokploy; ensure no secrets in image layers (reuse container CI check)
- [x] 5.1 Create `.github/workflows/deploy-staging.yml` gated on successful CI on `main`: build image, push `ghcr.io/...:<sha>` + `:staging`, trigger Dokploy `compose.deploy`
- [x] 5.2 Create `.github/workflows/promote-production.yml` (`workflow_dispatch` + confirmation): retag same digest as `:production`, deploy production stack, smoke
- [x] 5.3 Wire migrate-before-serve (Compose migrate service) and healthcheck on `/up`
- [x] 5.4 Add post-deploy smoke script/job for `/up`, `/`, `/admin/login` returning 200
- [x] 5.5 Store orchestration secrets only in GitHub; Laravel/DB/R2/Resend only in Dokploy; ensure no secrets in image layers
## 6. Phase 0 exit evidence
- [ ] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output)
- [ ] 6.2 Verify rollback to previous SHA works once
- [ ] 6.2 Verify rollback to previous SHA works once on staging
- [ ] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
- [ ] 6.5 Report in SPEC §24 format and mark this change ready to archive after merge
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (13) also complete

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Trigger a Dokploy Compose deploy and wait until it finishes.
# Required env:
# DOKPLOY_URL e.g. https://panel.example.com
# DOKPLOY_API_KEY x-api-key value
# DOKPLOY_COMPOSE_ID target compose id
# Optional env:
# DEPLOY_TITLE deployment title (default: GitHub deploy)
# DEPLOY_TIMEOUT_SEC total wait seconds (default: 900)
# DEPLOY_POLL_SEC poll interval (default: 10)
set -euo pipefail
: "${DOKPLOY_URL:?DOKPLOY_URL is required}"
: "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}"
: "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}"
DOKPLOY_URL="${DOKPLOY_URL%/}"
DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}"
DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}"
DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}"
api() {
local method="$1"
local path="$2"
shift 2
curl -fsS -X "$method" \
-H "accept: application/json" \
-H "content-type: application/json" \
-H "x-api-key: ${DOKPLOY_API_KEY}" \
"${DOKPLOY_URL}/api${path}" \
"$@"
}
echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..."
api POST "/compose.deploy" \
-d "$(jq -n \
--arg composeId "$DOKPLOY_COMPOSE_ID" \
--arg title "$DEPLOY_TITLE" \
'{composeId: $composeId, title: $title}')" >/dev/null
echo "Waiting for deployment to finish (timeout ${DEPLOY_TIMEOUT_SEC}s)..."
deadline=$((SECONDS + DEPLOY_TIMEOUT_SEC))
last_status=""
while (( SECONDS < deadline )); do
payload="$(api GET "/deployment.allByCompose?composeId=${DOKPLOY_COMPOSE_ID}")"
latest="$(echo "$payload" | jq -c 'if type=="array" then .[0] else . end')"
if [[ -z "$latest" || "$latest" == "null" ]]; then
echo "No deployment records yet; retrying..."
sleep "$DEPLOY_POLL_SEC"
continue
fi
status="$(echo "$latest" | jq -r '.status // .deploymentStatus // empty')"
created_at="$(echo "$latest" | jq -r '.createdAt // .created_at // empty')"
title="$(echo "$latest" | jq -r '.title // .titleLog // empty')"
if [[ "$status" != "$last_status" ]]; then
echo "Deployment status=${status:-unknown} title=${title:-n/a} createdAt=${created_at:-n/a}"
last_status="$status"
fi
case "${status,,}" in
done|success|successful|finished)
echo "Dokploy deployment succeeded."
exit 0
;;
error|failed|failure)
echo "Dokploy deployment failed." >&2
echo "$latest" | jq . >&2 || true
exit 1
;;
esac
sleep "$DEPLOY_POLL_SEC"
done
echo "Timed out waiting for Dokploy deployment after ${DEPLOY_TIMEOUT_SEC}s." >&2
api GET "/deployment.allByCompose?composeId=${DOKPLOY_COMPOSE_ID}" | jq . >&2 || true
exit 1

56
scripts/deploy/smoke.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Post-deploy HTTP smoke checks for Amare.
# Required env:
# SMOKE_BASE_URL e.g. https://staging.example.com
# Optional env:
# SMOKE_RETRIES attempts per path (default: 30)
# SMOKE_SLEEP_SEC sleep between attempts (default: 5)
# SMOKE_PATHS space-separated paths (default: /up / /admin/login)
set -euo pipefail
: "${SMOKE_BASE_URL:?SMOKE_BASE_URL is required}"
SMOKE_BASE_URL="${SMOKE_BASE_URL%/}"
SMOKE_RETRIES="${SMOKE_RETRIES:-30}"
SMOKE_SLEEP_SEC="${SMOKE_SLEEP_SEC:-5}"
SMOKE_PATHS="${SMOKE_PATHS:-/up / /admin/login}"
check_path() {
local path="$1"
local url="${SMOKE_BASE_URL}${path}"
local attempt
local code
for ((attempt = 1; attempt <= SMOKE_RETRIES; attempt++)); do
code="$(curl -sS -o /tmp/amare-smoke-body -w '%{http_code}' --max-time 20 "$url" 2>/dev/null || true)"
if [[ "$code" == "200" ]]; then
echo "OK ${path} (HTTP ${code}) attempt=${attempt}"
return 0
fi
echo "WAIT ${path} (HTTP ${code:-000}) attempt=${attempt}/${SMOKE_RETRIES}"
sleep "$SMOKE_SLEEP_SEC"
done
echo "FAIL ${path} after ${SMOKE_RETRIES} attempts" >&2
if [[ -f /tmp/amare-smoke-body ]]; then
head -c 500 /tmp/amare-smoke-body >&2 || true
echo >&2
fi
return 1
}
echo "Smoke against ${SMOKE_BASE_URL}"
failed=0
for path in $SMOKE_PATHS; do
if ! check_path "$path"; then
failed=1
fi
done
if (( failed != 0 )); then
echo "Smoke checks failed." >&2
exit 1
fi
echo "Smoke checks passed."

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\TestCase;
class TrustedProxyTest extends TestCase
{
public function test_forwarded_proto_https_is_recognized_behind_proxy(): void
{
$response = $this->get('/up', [
'HTTP_X_FORWARDED_PROTO' => 'https',
'HTTP_X_FORWARDED_FOR' => '203.0.113.10',
]);
$response->assertOk();
$this->assertTrue(request()->secure());
$this->assertSame('203.0.113.10', request()->ip());
}
}