feat: split into 3 docker-compose stacks (main / gitea / wedding_photo)

Repo passa a viver dentro de infra/, com três stacks isoladas que
compartilham uma network Docker externa ('infra-net'):

main/        infra base: postgres + redis + minio + minio-init + pgadmin
             + caddy. Postgres roda em modo multi-banco; o init script
             cria os DBs 'wedding' e 'gitea' com roles dedicadas. MinIO
             tem um bucket inicial criado pelo minio-init com anonymous
             download + CORS. Caddy é o único container expondo 80/443
             e roteia por hostname: gitea.{DOMAIN_BASE} / wedding.* /
             pgadmin.* / minio.* / media.* (rewrite de bucket).

gitea/       gitea + act_runner. Gitea liga no postgres compartilhado e
             usa redis pra cache+sessões. O runner ganha um Dockerfile
             pequeno que adiciona docker CLI por cima do
             gitea/act_runner pra workflows poderem chamar 'docker
             build'. Bootstrap do token de runner documentado no
             .env.example.

wedding_photo/  Só a aplicação: 'wedding_app' (FastAPI + SPA) +
             postgres-backup + media-backup. Os bancos e o MinIO vêm
             da stack main/. A app usa extra_hosts: host-gateway pra
             alcançar media.{DOMAIN_BASE} via Caddy mesmo em dev local
             — assim a assinatura S3 fecha com o host que o browser
             usa pra fazer PUT.

Orquestração:
- Makefile no root: 'make up' sobe tudo na ordem (main -> gitea ->
  wedding_photo). 'make up-{main,gitea,wedding}' pra controle
  granular. 'make logs-*', 'make down', 'make status', 'make pull-*'.
- network.sh cria a 'infra-net' antes de qualquer up; idempotente.
- Cada stack tem seu próprio .env.example. As creds compartilhadas
  (DOMAIN_BASE, MINIO_ROOT_*, WEDDING_DB_*) precisam casar entre
  main/.env e o consumidor (gitea/.env ou wedding_photo/.env).
- .gitignore ignora todas as pastas data/ dos volumes.
This commit is contained in:
Claude 2026-06-08 23:34:14 +00:00
parent f882f8a1c8
commit 5a19753013
No known key found for this signature in database
71 changed files with 616 additions and 257 deletions

View File

@ -1,57 +0,0 @@
# =============================================================================
# Edite essas variáveis e salve como .env (mesmo diretório do docker-compose.yml)
# =============================================================================
# ----- Domínios (apontar DNS A/AAAA pra IP do VPS antes do `docker compose up`)
DOMAIN=casamento.exemplo.com.br
MEDIA_DOMAIN=midia.exemplo.com.br
ACME_EMAIL=voce@exemplo.com
# ----- Postgres
POSTGRES_USER=wedding
POSTGRES_PASSWORD=troque-isso-por-uma-senha-forte
POSTGRES_DB=wedding
# ----- MinIO (root credentials = também usadas pelo app como S3 access keys)
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=troque-isso-por-uma-senha-forte
S3_BUCKET=wedding-media
S3_REGION=us-east-1
# Endpoint INTERNO usado pelo app pra falar com o MinIO. Use o do Caddy
# (https://${MEDIA_DOMAIN}) — necessário porque a assinatura S3 inclui o host
# e o browser faz upload direto pra MEDIA_DOMAIN. Hairpin via DNS público é OK.
# Pra evitar hairpin, ajuste extra_hosts no compose pra resolver MEDIA_DOMAIN
# pra IP interno e set S3_INTERNAL_ENDPOINT=http://minio:9000 (avançado).
S3_INTERNAL_ENDPOINT=
# ----- App
COUPLE_NAMES=Stefanie & Leandro
EVENT_DATE=07 de junho de 2026
# ----- Admin (painel dos noivos)
ADMIN_PASSWORD=senha-forte-compartilhada-entre-os-dois
SESSION_SECRET=gere-um-uuid-longo-aqui-min-32-chars
ALLOWED_ADMIN_EMAILS=voce@exemplo.com,outro@exemplo.com
# ----- Backup (vai pra ./backups/ no host por padrão)
TZ=America/Sao_Paulo
# Cron schedule para o Postgres: aceita formato cron OR @daily/@hourly/etc
BACKUP_SCHEDULE_DB=@daily
# Cron para o MinIO (mc mirror é incremental, pode rodar mais vezes)
BACKUP_SCHEDULE_MEDIA=0 3 * * *
# Retenção (camadas: dias / semanas / meses) — só pro Postgres
BACKUP_KEEP_DAYS=14
BACKUP_KEEP_WEEKS=4
BACKUP_KEEP_MONTHS=6
# Backup remoto OPCIONAL — espelha o bucket pra outro provedor S3 (R2,
# B2, AWS S3, outro MinIO). Deixe vazio pra ficar só com backup local.
# Exemplo com Cloudflare R2:
# BACKUP_REMOTE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
# BACKUP_REMOTE_ACCESS_KEY=...
# BACKUP_REMOTE_SECRET_KEY=...
# BACKUP_REMOTE_BUCKET=wedding-media-backup
BACKUP_REMOTE_ENDPOINT=
BACKUP_REMOTE_ACCESS_KEY=
BACKUP_REMOTE_SECRET_KEY=
BACKUP_REMOTE_BUCKET=

15
.gitignore vendored
View File

@ -11,9 +11,6 @@ build/
.env.*.local .env.*.local
!.env.example !.env.example
# Backup output on the VPS
backups/
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
@ -27,5 +24,17 @@ venv/
.ruff_cache/ .ruff_cache/
*.egg-info/ *.egg-info/
# Diretórios de dados (bind mounts dos volumes Docker)
infra/main/postgres/data/
infra/main/redis/data/
infra/main/minio/data/
infra/main/pgadmin/data/
infra/main/caddy/data/
infra/main/caddy/config/
infra/gitea/gitea/data/
infra/gitea/gitea/config/
infra/gitea/runner/data/
infra/wedding_photo/backups/
.vscode/ .vscode/
.idea/ .idea/

View File

@ -1,25 +0,0 @@
{
email {$ACME_EMAIL}
}
{$DOMAIN} {
encode zstd gzip
request_body {
max_size 600MB
}
reverse_proxy app:3000 {
transport http {
response_header_timeout 10m
dial_timeout 30s
}
}
}
{$MEDIA_DOMAIN} {
encode gzip
reverse_proxy minio:9000 {
header_up Host {upstream_hostport}
}
}

View File

@ -1,32 +1,82 @@
.PHONY: install dev-web dev-api migrate build docker-up docker-down docker-logs lint typecheck .PHONY: help network up down restart status \
up-main up-gitea up-wedding \
down-main down-gitea down-wedding \
logs-main logs-gitea logs-wedding \
pull-main pull-gitea pull-wedding \
rebuild-wedding
install: NET := infra-net
pnpm install DC_MAIN := docker compose -f infra/main/docker-compose.yml --env-file infra/main/.env
cd apps/api && uv sync DC_GITEA := docker compose -f infra/gitea/docker-compose.yml --env-file infra/gitea/.env
DC_WEDDING := docker compose -f infra/wedding_photo/docker-compose.yml --env-file infra/wedding_photo/.env
dev-web: help:
pnpm -F @leetete/web dev @echo "Stacks: main (postgres+redis+minio+pgadmin+caddy) | gitea | wedding_photo"
@echo ""
@echo " make up Sobe tudo na ordem (main -> gitea -> wedding)"
@echo " make down Desce tudo na ordem inversa"
@echo " make restart down + up"
@echo " make status ps de todas as stacks"
@echo ""
@echo " make up-main Sobe só a stack main"
@echo " make up-gitea Sobe só o gitea"
@echo " make up-wedding Build + up do app wedding"
@echo " make rebuild-wedding Force rebuild do app wedding"
@echo " make down-{main,gitea,wedding}"
@echo " make logs-{main,gitea,wedding}"
@echo " make pull-{main,gitea,wedding}"
@echo " make network Cria a network external '$(NET)' (idempotente)"
dev-api: network:
cd apps/api && uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 3000 @./network.sh
migrate: up: network up-main up-gitea up-wedding
cd apps/api && uv run python -m app.db.migrate
build: down: down-wedding down-gitea down-main
pnpm -F @leetete/web build
typecheck: restart: down up
pnpm -F @leetete/web typecheck
lint: status:
cd apps/api && uv run ruff check app @$(DC_MAIN) ps || true
@$(DC_GITEA) ps || true
@$(DC_WEDDING) ps || true
docker-up: up-main: network
docker compose up -d --build $(DC_MAIN) up -d
docker-down: up-gitea: network
docker compose down $(DC_GITEA) up -d
docker-logs: up-wedding: network
docker compose logs -f app $(DC_WEDDING) up -d --build
rebuild-wedding: network
$(DC_WEDDING) build --no-cache
$(DC_WEDDING) up -d
down-main:
$(DC_MAIN) down
down-gitea:
$(DC_GITEA) down
down-wedding:
$(DC_WEDDING) down
logs-main:
$(DC_MAIN) logs -f
logs-gitea:
$(DC_GITEA) logs -f
logs-wedding:
$(DC_WEDDING) logs -f
pull-main:
$(DC_MAIN) pull
pull-gitea:
$(DC_GITEA) pull
pull-wedding:
$(DC_WEDDING) pull

View File

@ -1,150 +0,0 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-wedding}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-wedding}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wedding} -d ${POSTGRES_DB:-wedding}"]
interval: 10s
timeout: 5s
retries: 10
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 15s
timeout: 10s
retries: 10
minio-init:
image: minio/mc:latest
restart: "no"
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: ${S3_BUCKET:-wedding-media}
entrypoint:
- /bin/sh
- -c
- |
set -e
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD"
mc mb --ignore-existing local/"$$S3_BUCKET"
mc anonymous set download local/"$$S3_BUCKET"
mc cors set local/"$$S3_BUCKET" /tmp/cors.json || true
echo "minio init done"
volumes:
- ./infra/minio-cors.json:/tmp/cors.json:ro
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-wedding}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-wedding}
S3_ENDPOINT: ${S3_INTERNAL_ENDPOINT:-https://${MEDIA_DOMAIN}}
S3_BUCKET: ${S3_BUCKET:-wedding-media}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
S3_PUBLIC_BASE_URL: https://${MEDIA_DOMAIN}/${S3_BUCKET:-wedding-media}
S3_FORCE_PATH_STYLE: "true"
COUPLE_NAMES: ${COUPLE_NAMES:-Stefanie & Leandro}
EVENT_DATE: ${EVENT_DATE:-}
PUBLIC_BASE_URL: https://${DOMAIN}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SESSION_SECRET: ${SESSION_SECRET}
ALLOWED_ADMIN_EMAILS: ${ALLOWED_ADMIN_EMAILS}
AUTO_MIGRATE: "true"
NODE_ENV: production
PORT: 3000
expose:
- "3000"
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
DOMAIN: ${DOMAIN}
MEDIA_DOMAIN: ${MEDIA_DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
- minio
postgres-backup:
image: prodrigestivill/postgres-backup-local:16-alpine
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: ${POSTGRES_DB:-wedding}
POSTGRES_USER: ${POSTGRES_USER:-wedding}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_EXTRA_OPTS: "--clean --if-exists"
SCHEDULE: ${BACKUP_SCHEDULE_DB:-@daily}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
BACKUP_KEEP_WEEKS: ${BACKUP_KEEP_WEEKS:-4}
BACKUP_KEEP_MONTHS: ${BACKUP_KEEP_MONTHS:-6}
TZ: ${TZ:-America/Sao_Paulo}
volumes:
- ./backups/postgres:/backups
media-backup:
image: alpine:3.20
restart: unless-stopped
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: ${S3_BUCKET:-wedding-media}
BACKUP_SCHEDULE_MEDIA: ${BACKUP_SCHEDULE_MEDIA:-0 3 * * *}
BACKUP_REMOTE_ENDPOINT: ${BACKUP_REMOTE_ENDPOINT:-}
BACKUP_REMOTE_ACCESS_KEY: ${BACKUP_REMOTE_ACCESS_KEY:-}
BACKUP_REMOTE_SECRET_KEY: ${BACKUP_REMOTE_SECRET_KEY:-}
BACKUP_REMOTE_BUCKET: ${BACKUP_REMOTE_BUCKET:-}
TZ: ${TZ:-America/Sao_Paulo}
volumes:
- ./backups/media:/backups
- ./infra/backup:/scripts:ro
entrypoint: ["/bin/sh", "/scripts/media-backup-entrypoint.sh"]
volumes:
postgres_data:
minio_data:
caddy_data:
caddy_config:

32
infra/gitea/.env.example Normal file
View File

@ -0,0 +1,32 @@
# =============================================================================
# infra/gitea/.env — copie pra .env e edite
# =============================================================================
# Casa com infra/main/.env
DOMAIN_BASE=localhost
TZ=America/Sao_Paulo
# Banco no postgres compartilhado (mesmas creds do main/.env)
GITEA_DB_NAME=gitea
GITEA_DB_USER=gitea
GITEA_DB_PASSWORD=troque-essa-senha-forte
# Porta SSH no host (pra git@gitea.localhost:user/repo.git)
GITEA_SSH_PORT=2222
# Cadastro: true = só admin convida; false = qualquer um cria conta
GITEA_DISABLE_REGISTRATION=true
GITEA_REQUIRE_SIGNIN=false
# ----- Gitea Actions Runner -----
# Como pegar o token (bootstrap):
# 1. Suba só o gitea: make up-gitea (runner vai falhar a 1ª vez, ok)
# 2. Acesse https://gitea.localhost e crie o usuário admin (no terminal:
# docker exec -it gitea gitea admin user create --username admin
# --password ... --email ... --admin)
# 3. UI: Site Administration → Actions → Runners → Create new runner
# 4. Copie o token aqui e: make up-gitea (runner registra e fica de pé)
GITEA_RUNNER_TOKEN=
GITEA_RUNNER_NAME=local-runner
# Labels exemplos. Adicione mais se quiser ambientes específicos.
GITEA_RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:act-22.04,node:docker://node:22-alpine,python:docker://python:3.12-slim

View File

@ -0,0 +1,67 @@
services:
gitea:
image: gitea/gitea:1.22
container_name: gitea
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
TZ: ${TZ:-America/Sao_Paulo}
# Banco em postgres compartilhado (stack main/)
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: postgres:5432
GITEA__database__NAME: ${GITEA_DB_NAME:-gitea}
GITEA__database__USER: ${GITEA_DB_USER:-gitea}
GITEA__database__PASSWD: ${GITEA_DB_PASSWORD}
# Servidor
GITEA__server__DOMAIN: gitea.${DOMAIN_BASE:-localhost}
GITEA__server__ROOT_URL: https://gitea.${DOMAIN_BASE:-localhost}/
GITEA__server__SSH_DOMAIN: gitea.${DOMAIN_BASE:-localhost}
GITEA__server__SSH_LISTEN_PORT: "22"
GITEA__server__SSH_PORT: "${GITEA_SSH_PORT:-2222}"
GITEA__server__HTTP_PORT: "3000"
# Cache (Redis compartilhado)
GITEA__cache__ENABLED: "true"
GITEA__cache__ADAPTER: redis
GITEA__cache__HOST: redis://redis:6379/0
# Sessões em Redis também
GITEA__session__PROVIDER: redis
GITEA__session__PROVIDER_CONFIG: redis://redis:6379/1
# Service
GITEA__service__DISABLE_REGISTRATION: ${GITEA_DISABLE_REGISTRATION:-true}
GITEA__service__REQUIRE_SIGNIN_VIEW: ${GITEA_REQUIRE_SIGNIN:-false}
# Actions
GITEA__actions__ENABLED: "true"
GITEA__actions__DEFAULT_ACTIONS_URL: github
ports:
# SSH pra git push/pull. UI passa pelo Caddy (porta 443).
- "${GITEA_SSH_PORT:-2222}:22"
volumes:
- ./gitea/data:/var/lib/gitea
- ./gitea/config:/etc/gitea
networks:
- infra-net
runner:
build:
context: ./runner
container_name: gitea_runner
restart: unless-stopped
depends_on:
- gitea
environment:
TZ: ${TZ:-America/Sao_Paulo}
GITEA_INSTANCE_URL: http://gitea:3000
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_TOKEN:-}
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-local-runner}
GITEA_RUNNER_LABELS: ${GITEA_RUNNER_LABELS:-ubuntu-latest:docker://catthehacker/ubuntu:act-22.04,node:docker://node:22-alpine,python:docker://python:3.12-slim}
volumes:
- ./runner/data:/data
- /var/run/docker.sock:/var/run/docker.sock
networks:
- infra-net
networks:
infra-net:
external: true
name: infra-net

View File

@ -0,0 +1,7 @@
FROM gitea/act_runner:latest
# Adiciona docker CLI pro runner conseguir buildar/pushar imagens dentro
# dos workflows (Action `docker/build-push-action` etc.). O socket do host
# é montado via volume no docker-compose.yml.
USER root
RUN apk add --no-cache docker-cli docker-cli-buildx

41
infra/main/.env.example Normal file
View File

@ -0,0 +1,41 @@
# =============================================================================
# infra/main/.env — copie pra .env e edite
# =============================================================================
# ----- Domínio + TLS -----
# Dev local: deixa "localhost". Caddy emite cert interno automático pra *.localhost
# Prod: usa teu domínio (ex.: lronetto.com.br). Apontar DNS:
# wedding.SEU.DOMINIO → IP do VPS
# gitea.SEU.DOMINIO → IP do VPS
# media.SEU.DOMINIO → IP do VPS
# pgadmin.SEU.DOMINIO → IP do VPS
# minio.SEU.DOMINIO → IP do VPS
DOMAIN_BASE=localhost
# Em produção: email pra Let's Encrypt renovar certs. Em dev pode ficar vazio.
ACME_EMAIL=
# Timezone (aplica em postgres-backup, cron de media-backup, etc.)
TZ=America/Sao_Paulo
# ----- Postgres: admin + bancos por app -----
POSTGRES_ADMIN_PASSWORD=troque-essa-senha-forte
WEDDING_DB_USER=wedding
WEDDING_DB_PASSWORD=troque-essa-senha-forte
WEDDING_DB_NAME=wedding
GITEA_DB_USER=gitea
GITEA_DB_PASSWORD=troque-essa-senha-forte
GITEA_DB_NAME=gitea
# ----- MinIO (root creds) -----
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=troque-essa-senha-forte
# Bucket criado já no boot pra ser consumido pela stack wedding_photo
WEDDING_BUCKET=wedding-media
# ----- pgAdmin (login web) -----
PGADMIN_EMAIL=admin@localhost
PGADMIN_PASSWORD=troque-essa-senha-forte

View File

@ -0,0 +1,50 @@
{
email {$ACME_EMAIL}
# Em dev local (*.localhost) Caddy emite cert interno automaticamente.
# Em produção, Let's Encrypt entra pelos hostnames reais.
}
# ----- Gitea (UI + git over HTTPS) -----
gitea.{$DOMAIN_BASE} {
encode zstd gzip
reverse_proxy gitea:3000 {
transport http {
response_header_timeout 10m
dial_timeout 30s
}
}
}
# ----- Wedding (app dos noivos) -----
wedding.{$DOMAIN_BASE} {
encode zstd gzip
request_body {
max_size 600MB
}
reverse_proxy wedding_app:3000 {
transport http {
response_header_timeout 10m
dial_timeout 30s
}
}
}
# ----- pgAdmin -----
pgadmin.{$DOMAIN_BASE} {
encode gzip
reverse_proxy pgadmin:80
}
# ----- MinIO console (admin web) -----
minio.{$DOMAIN_BASE} {
encode gzip
reverse_proxy minio:9001
}
# ----- MinIO S3 API público (uploads/downloads do bucket) -----
media.{$DOMAIN_BASE} {
encode gzip
reverse_proxy minio:9000 {
header_up Host {upstream_hostport}
}
}

View File

@ -0,0 +1,121 @@
services:
postgres:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
POSTGRES_DB: postgres
TZ: ${TZ:-America/Sao_Paulo}
WEDDING_DB_USER: ${WEDDING_DB_USER:-wedding}
WEDDING_DB_PASSWORD: ${WEDDING_DB_PASSWORD}
WEDDING_DB_NAME: ${WEDDING_DB_NAME:-wedding}
GITEA_DB_USER: ${GITEA_DB_USER:-gitea}
GITEA_DB_PASSWORD: ${GITEA_DB_PASSWORD}
GITEA_DB_NAME: ${GITEA_DB_NAME:-gitea}
volumes:
- ./postgres/data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 10
networks:
- infra-net
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
command: ["redis-server", "--save", "60", "1", "--appendonly", "yes"]
volumes:
- ./redis/data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- infra-net
minio:
image: minio/minio:latest
container_name: minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- ./minio/data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 15s
timeout: 10s
retries: 10
networks:
- infra-net
minio-init:
image: minio/mc:latest
container_name: minio-init
restart: "no"
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
WEDDING_BUCKET: ${WEDDING_BUCKET:-wedding-media}
volumes:
- ./minio/init.sh:/init.sh:ro
- ./minio/cors.json:/cors.json:ro
entrypoint: ["/bin/sh", "/init.sh"]
networks:
- infra-net
pgadmin:
image: dpage/pgadmin4:latest
container_name: pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
PGADMIN_DISABLE_POSTFIX: "true"
PGADMIN_CONFIG_SERVER_MODE: "True"
PGADMIN_CONFIG_PROXY_X_FOR_COUNT: "1"
PGADMIN_CONFIG_PROXY_X_PROTO_COUNT: "1"
volumes:
- ./pgadmin/data:/var/lib/pgadmin
- ./pgadmin/servers.json:/pgadmin4/servers.json:ro
depends_on:
postgres:
condition: service_healthy
networks:
- infra-net
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
DOMAIN_BASE: ${DOMAIN_BASE:-localhost}
ACME_EMAIL: ${ACME_EMAIL:-}
WEDDING_BUCKET: ${WEDDING_BUCKET:-wedding-media}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
networks:
- infra-net
networks:
infra-net:
external: true
name: infra-net

13
infra/main/minio/init.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
# Inicializa o bucket usado pela stack wedding_photo. Roda como container
# one-shot depois que o MinIO ficou healthy.
set -e
mc alias set --quiet local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
mc mb --ignore-existing "local/$WEDDING_BUCKET"
mc anonymous set download "local/$WEDDING_BUCKET"
mc cors set "local/$WEDDING_BUCKET" /cors.json 2>/dev/null || \
echo "[minio-init] cors set ignorado (versão antiga do mc?)"
echo "[minio-init] bucket '$WEDDING_BUCKET' pronto (anonymous download + CORS)"

View File

@ -0,0 +1,13 @@
{
"Servers": {
"1": {
"Name": "Postgres (infra)",
"Group": "Servers",
"Host": "postgres",
"Port": 5432,
"MaintenanceDB": "postgres",
"Username": "postgres",
"SSLMode": "prefer"
}
}
}

View File

@ -0,0 +1,17 @@
#!/bin/bash
# Cria os bancos e roles dedicados a cada app. Rodado pelo postgres entrypoint
# uma única vez, no primeiro boot. Subsequente: ignorado (init scripts só
# rodam quando PGDATA está vazio).
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE ROLE "${WEDDING_DB_USER}" WITH LOGIN PASSWORD '${WEDDING_DB_PASSWORD}';
CREATE DATABASE "${WEDDING_DB_NAME}" OWNER "${WEDDING_DB_USER}";
GRANT ALL PRIVILEGES ON DATABASE "${WEDDING_DB_NAME}" TO "${WEDDING_DB_USER}";
CREATE ROLE "${GITEA_DB_USER}" WITH LOGIN PASSWORD '${GITEA_DB_PASSWORD}';
CREATE DATABASE "${GITEA_DB_NAME}" OWNER "${GITEA_DB_USER}";
GRANT ALL PRIVILEGES ON DATABASE "${GITEA_DB_NAME}" TO "${GITEA_DB_USER}";
EOSQL
echo "[postgres-init] roles + databases criados para wedding (${WEDDING_DB_NAME}) e gitea (${GITEA_DB_NAME})"

View File

@ -0,0 +1,53 @@
# =============================================================================
# infra/wedding_photo/.env — copie pra .env e edite
# As variáveis com prefixo compartilhado (MINIO_*, WEDDING_DB_*) precisam ter
# os MESMOS valores aqui e em infra/main/.env (mesmas creds do banco e do MinIO).
# =============================================================================
# Compartilhadas com main/
DOMAIN_BASE=localhost
TZ=America/Sao_Paulo
# DB (mesmo do main/.env)
WEDDING_DB_USER=wedding
WEDDING_DB_PASSWORD=troque-essa-senha-forte
WEDDING_DB_NAME=wedding
# MinIO (mesmo do main/.env)
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=troque-essa-senha-forte
WEDDING_BUCKET=wedding-media
S3_REGION=us-east-1
# Opcional: sobreescrevem o default se quiser endpoints customizados.
# Default monta a partir de DOMAIN_BASE + WEDDING_BUCKET:
# S3_ENDPOINT=https://media.${DOMAIN_BASE}
# S3_PUBLIC_BASE_URL=https://media.${DOMAIN_BASE}/${WEDDING_BUCKET}
# S3_ENDPOINT=
# S3_PUBLIC_BASE_URL=
# ----- App (específico desta stack) -----
COUPLE_NAMES=Stefanie & Leandro
EVENT_DATE=07 de junho de 2026
ADMIN_PASSWORD=senha-forte-compartilhada-entre-os-dois
SESSION_SECRET=gere-um-uuid-longo-aqui-min-32-chars
ALLOWED_ADMIN_EMAILS=voce@exemplo.com,outro@exemplo.com
# ----- Backups (escrevem em ./backups/ no host) -----
BACKUP_SCHEDULE_DB=@daily
BACKUP_SCHEDULE_MEDIA=0 3 * * *
BACKUP_KEEP_DAYS=14
BACKUP_KEEP_WEEKS=4
BACKUP_KEEP_MONTHS=6
# Backup remoto OPCIONAL (R2, B2, outro MinIO). Vazio = só backup local.
# Exemplo com Cloudflare R2:
# BACKUP_REMOTE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
# BACKUP_REMOTE_ACCESS_KEY=...
# BACKUP_REMOTE_SECRET_KEY=...
# BACKUP_REMOTE_BUCKET=wedding-media-backup
BACKUP_REMOTE_ENDPOINT=
BACKUP_REMOTE_ACCESS_KEY=
BACKUP_REMOTE_SECRET_KEY=
BACKUP_REMOTE_BUCKET=

View File

@ -0,0 +1,26 @@
.PHONY: install dev-web dev-api migrate build typecheck lint
# Comandos de DESENVOLVIMENTO LOCAL (rodam fora do Docker).
# Pra subir/derrubar a stack, use o Makefile do root: `make up-wedding` etc.
install:
pnpm install
cd apps/api && uv sync
dev-web:
pnpm -F @leetete/web dev
dev-api:
cd apps/api && uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 3000
migrate:
cd apps/api && uv run python -m app.db.migrate
build:
pnpm -F @leetete/web build
typecheck:
pnpm -F @leetete/web typecheck
lint:
cd apps/api && uv run ruff check app

View File

@ -0,0 +1,81 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: wedding_app
restart: unless-stopped
environment:
DATABASE_URL: postgres://${WEDDING_DB_USER:-wedding}:${WEDDING_DB_PASSWORD}@postgres:5432/${WEDDING_DB_NAME:-wedding}
# Endpoint pra assinar/servir uploads pelo browser. Em prod usa
# https://media.{DOMAIN_BASE}. Em dev local também — o app acessa via
# extra_hosts -> host gateway -> Caddy -> minio.
S3_ENDPOINT: ${S3_ENDPOINT:-https://media.${DOMAIN_BASE:-localhost}}
S3_BUCKET: ${WEDDING_BUCKET:-wedding-media}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-https://media.${DOMAIN_BASE:-localhost}/${WEDDING_BUCKET:-wedding-media}}
S3_FORCE_PATH_STYLE: "true"
COUPLE_NAMES: ${COUPLE_NAMES:-Stefanie & Leandro}
EVENT_DATE: ${EVENT_DATE:-}
PUBLIC_BASE_URL: https://wedding.${DOMAIN_BASE:-localhost}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SESSION_SECRET: ${SESSION_SECRET}
ALLOWED_ADMIN_EMAILS: ${ALLOWED_ADMIN_EMAILS}
AUTO_MIGRATE: "true"
NODE_ENV: production
PORT: "3000"
# Em dev local, faz `media.localhost` (e o domínio do site) apontarem pro
# host gateway, pra o app dentro do container alcançar o Caddy do main/.
extra_hosts:
- "media.${DOMAIN_BASE:-localhost}:host-gateway"
- "wedding.${DOMAIN_BASE:-localhost}:host-gateway"
networks:
- infra-net
postgres-backup:
image: prodrigestivill/postgres-backup-local:16-alpine
container_name: wedding_pg_backup
restart: unless-stopped
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: ${WEDDING_DB_NAME:-wedding}
POSTGRES_USER: ${WEDDING_DB_USER:-wedding}
POSTGRES_PASSWORD: ${WEDDING_DB_PASSWORD}
POSTGRES_EXTRA_OPTS: "--clean --if-exists"
SCHEDULE: ${BACKUP_SCHEDULE_DB:-@daily}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
BACKUP_KEEP_WEEKS: ${BACKUP_KEEP_WEEKS:-4}
BACKUP_KEEP_MONTHS: ${BACKUP_KEEP_MONTHS:-6}
TZ: ${TZ:-America/Sao_Paulo}
volumes:
- ./backups/postgres:/backups
networks:
- infra-net
media-backup:
image: alpine:3.20
container_name: wedding_media_backup
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: ${WEDDING_BUCKET:-wedding-media}
BACKUP_SCHEDULE_MEDIA: ${BACKUP_SCHEDULE_MEDIA:-0 3 * * *}
BACKUP_REMOTE_ENDPOINT: ${BACKUP_REMOTE_ENDPOINT:-}
BACKUP_REMOTE_ACCESS_KEY: ${BACKUP_REMOTE_ACCESS_KEY:-}
BACKUP_REMOTE_SECRET_KEY: ${BACKUP_REMOTE_SECRET_KEY:-}
BACKUP_REMOTE_BUCKET: ${BACKUP_REMOTE_BUCKET:-}
TZ: ${TZ:-America/Sao_Paulo}
volumes:
- ./backups/media:/backups
- ./infra/backup:/scripts:ro
entrypoint: ["/bin/sh", "/scripts/media-backup-entrypoint.sh"]
networks:
- infra-net
networks:
infra-net:
external: true
name: infra-net

11
network.sh Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
NET="${INFRA_NETWORK:-infra-net}"
if docker network inspect "$NET" >/dev/null 2>&1; then
echo "[network] '$NET' já existe"
else
docker network create --driver bridge "$NET" >/dev/null
echo "[network] '$NET' criada"
fi