- Add DEVOPS.md with complete deployment guide for apolo server - Add DATABASE.md (unified schema documentation) - Fix backend Dockerfile: Go 1.24 -> 1.23 (current stable) - Add marketplace Dockerfile with pnpm + static-web-server - Migrate marketplace from npm to pnpm - Remove duplicate database-schema.md and DATABASE_SCHEMA.md
22 KiB
DevOps Guide - SaveInMed
Guia de operações e infraestrutura do SaveInMed.
Scaleway Container Registry
1. Login no Registry
# Login no registry Scaleway (usar secret do CI/CD)
echo "${SCW_SECRET_KEY}" | docker login rg.fr-par.scw.cloud -u nologin --password-stdin
Caution
NUNCA commitar credenciais no código. Use variáveis de ambiente ou secrets do CI/CD.
2. Build da Imagem
# Build com platform específico (importante para deploy em servidores AMD64)
docker build --platform linux/amd64 --no-cache -t saveinmed-frontend:latest .
# Com BuildKit para cache otimizado
DOCKER_BUILDKIT=1 docker build \
--platform linux/amd64 \
--cache-from rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest \
-t saveinmed-frontend:latest .
3. Tag e Push
# Tag com versão e latest
docker tag saveinmed-frontend:latest \
rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:${VERSION}
docker tag saveinmed-frontend:latest \
rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest
# Push ambas as tags
docker push rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:${VERSION}
docker push rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest
4. Variáveis de Ambiente (CI/CD)
# Secrets do Forgejo/GitHub Actions
SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # IAM → API Keys
SCW_REGISTRY_NAMESPACE=funcscwinfrastructure... # Container Registry → Namespace
Melhores Práticas de Versionamento
Semantic Versioning (SemVer)
MAJOR.MINOR.PATCH
Exemplo: 1.2.3
- MAJOR (1): Breaking changes
- MINOR (2): Novas features (backwards compatible)
- PATCH (3): Bug fixes
Estratégias de Tag
| Tag | Uso | Quando |
|---|---|---|
latest |
Desenvolvimento | Apenas em dev/staging |
v1.2.3 |
Produção | Release oficial |
v1.2.3-rc.1 |
Release Candidate | Pré-release para testes |
sha-abc1234 |
CI/CD | Cada commit (rastreabilidade) |
main-20241229 |
Branch + Data | Builds automáticos |
Exemplo de Workflow CI/CD
# .forgejo/workflows/deploy.yaml
env:
REGISTRY: rg.fr-par.scw.cloud
NAMESPACE: ${{ secrets.SCW_REGISTRY_NAMESPACE }}
jobs:
build:
steps:
- name: Generate version
run: |
# Usar git tag se existir, senão usar SHA
VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Login to Scaleway Registry
run: |
echo "${{ secrets.SCW_SECRET_KEY }}" | \
docker login $REGISTRY -u nologin --password-stdin
- name: Build and Push
run: |
docker build --platform linux/amd64 \
-t $REGISTRY/$NAMESPACE/saveinmed-frontend:$VERSION \
-t $REGISTRY/$NAMESPACE/saveinmed-frontend:latest .
docker push $REGISTRY/$NAMESPACE/saveinmed-frontend:$VERSION
docker push $REGISTRY/$NAMESPACE/saveinmed-frontend:latest
✅ Boas Práticas
- Sempre usar tags específicas em produção (nunca
latest) - Imagens imutáveis: uma vez publicada, nunca sobrescrever
- Multi-stage builds para imagens menores
- Scan de vulnerabilidades antes do deploy
- Cleanup automático de imagens antigas no registry
Deploy no Servidor (apolo)
Pré-requisitos
- Servidor:
apolo.rede5.com.br - SO: AlmaLinux com Podman
- Rede:
web_proxy(para comunicação com Traefik)
Estrutura de Diretórios
/mnt/data/saveinmed/
├── backend/.env # Variáveis de ambiente backend (Go)
├── backoffice/.env # Variáveis de ambiente backoffice (NestJS)
└── marketplace/.env # Variáveis de ambiente marketplace (Vite/React)
/etc/containers/systemd/
├── saveinmed-backend.container
├── saveinmed-backoffice.container
└── saveinmed-marketplace.container
Note
Como as imagens vêm do Scaleway Registry, não é necessário clonar o repo no servidor. O código está empacotado na imagem Docker.
Passo 1: Criar Diretórios e Arquivos .env
# Conectar no servidor
ssh root@apolo
# Criar estrutura de diretórios
mkdir -p /mnt/data/saveinmed/{backend,backoffice,marketplace}
Backend .env (/mnt/data/saveinmed/backend/.env)
# Geral
ENV=production
PORT=8080
TZ=America/Sao_Paulo
# Database
DATABASE_URL=postgresql://USER:PASS@postgres-main:5432/saveinmed?sslmode=disable
# Redis
REDIS_URL=redis://:PASSWORD@redis-saveinmed:6379/0
# JWT
JWT_SECRET=GERAR_SECRET_32_CHARS
JWT_EXPIRATION=7d
# CORS
CORS_ORIGINS=https://saveinmed.com.br,https://api.saveinmed.com.br
# Cookies
COOKIE_SECRET=GERAR_SECRET
COOKIE_DOMAIN=saveinmed.com.br
# Stripe
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Scaleway Storage
SCW_ACCESS_KEY=SCWxxx
SCW_SECRET_KEY=xxx
SCW_REGION=fr-par
SCW_BUCKET_NAME=saveinmed-uploads
SCW_ENDPOINT=https://s3.fr-par.scw.cloud
Marketplace .env (/mnt/data/saveinmed/marketplace/.env)
# Vite/React SPA - variáveis são injetadas no build
# Para produção, configure no Dockerfile ou CI/CD
VITE_API_URL=https://api.saveinmed.com.br
Note
O marketplace é uma SPA (Single Page Application) servida pelo nginx. As variáveis
VITE_*são injetadas em tempo de build, não de runtime.
Passo 2: Criar Arquivos Quadlet
Important
Quadlet é o sistema nativo do Podman para gerenciar containers como serviços systemd.
Backend (/etc/containers/systemd/saveinmed-backend.container)
[Unit]
Description=SaveInMed Backend
After=network-online.target postgres-main.service
[Container]
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backend:latest
ContainerName=saveinmed-backend
# Variáveis de ambiente
EnvironmentFile=/mnt/data/saveinmed/backend/.env
# Rede interna (Traefik)
Network=web_proxy
PublishPort=8080:8080
# Labels Traefik
Label=traefik.enable=true
Label=traefik.http.routers.saveinmed-backend.rule=Host(`api.saveinmed.com.br`)
Label=traefik.http.routers.saveinmed-backend.entrypoints=websecure
Label=traefik.http.routers.saveinmed-backend.tls.certresolver=myresolver
[Install]
WantedBy=multi-user.target
Marketplace (/etc/containers/systemd/saveinmed-marketplace.container)
[Unit]
Description=SaveInMed Marketplace (Vite/React SPA)
After=network-online.target saveinmed-backend.service
[Container]
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-marketplace:latest
ContainerName=saveinmed-marketplace
Pull=never
# serve roda na porta 3000
Network=web_proxy
PublishPort=3010:3000
# Labels Traefik
Label=traefik.enable=true
Label=traefik.http.routers.saveinmed-marketplace.rule=Host(`saveinmed.com.br`)
Label=traefik.http.routers.saveinmed-marketplace.entrypoints=websecure
Label=traefik.http.routers.saveinmed-marketplace.tls.certresolver=myresolver
Label=traefik.http.services.saveinmed-marketplace.loadbalancer.server.port=3000
[Install]
WantedBy=multi-user.target
Backoffice (/etc/containers/systemd/saveinmed-backoffice.container)
[Unit]
Description=SaveInMed Backoffice
After=network-online.target saveinmed-backend.service
[Container]
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backoffice:latest
ContainerName=saveinmed-backoffice
Pull=never
EnvironmentFile=/mnt/data/saveinmed/backoffice/.env
Network=web_proxy
PublishPort=3011:3000
Label=traefik.enable=true
Label=traefik.http.routers.saveinmed-backoffice.rule=Host(`backoffice.saveinmed.com.br`)
Label=traefik.http.routers.saveinmed-backoffice.entrypoints=websecure
Label=traefik.http.routers.saveinmed-backoffice.tls.certresolver=myresolver
Label=traefik.http.services.saveinmed-backoffice.loadbalancer.server.port=3000
[Install]
WantedBy=multi-user.target
Passo 3: Ativar e Iniciar Serviços
# Recarregar configurações do systemd
systemctl daemon-reload
# Iniciar serviços
systemctl start saveinmed-backend
systemctl start saveinmed-backoffice
systemctl start saveinmed-marketplace
# Habilitar para iniciar no boot
systemctl enable saveinmed-backend
systemctl enable saveinmed-backoffice
systemctl enable saveinmed-marketplace
# Verificar status
systemctl status saveinmed-backend
systemctl status saveinmed-marketplace
Passo 4: Atualizar Imagem (sem rebuild)
Como você não vai fazer build da imagem localmente, o processo de atualização é:
# 1. Fazer pull da nova imagem
podman pull rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backend:latest
# 2. Reiniciar o serviço (vai usar a nova imagem)
systemctl restart saveinmed-backend
Ou para todos:
# Script de atualização
for svc in backend backoffice marketplace; do
podman pull rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-${svc}:latest
systemctl restart saveinmed-${svc}
done
Configuração do Traefik
O Traefik já está configurado em /etc/containers/systemd/traefik.container:
[Container]
Image=docker.io/library/traefik:v3.0
ContainerName=traefik
Network=web_proxy
PublishPort=80:80
PublishPort=443:443
Volume=/run/podman/podman.sock:/run/podman/podman.sock:ro
Volume=/opt/traefik/letsencrypt:/letsecrypt
Exec=--providers.docker=true \
--providers.docker.endpoint=unix:///run/podman/podman.sock \
--providers.docker.exposedByDefault=false \
--entryPoints.web.address=:80 \
--entryPoints.websecure.address=:443 \
--certificatesResolvers.myresolver.acme.tlschallenge=true \
--certificatesResolvers.myresolver.acme.email=seu-email@dominio.com \
--certificatesResolvers.myresolver.acme.storage=/letsecrypt/acme.json
Tip
Os certificados SSL são gerados automaticamente pelo Let's Encrypt via TLS challenge.
Comandos Úteis
# Ver containers rodando
podman ps
# Ver logs de um serviço
journalctl -u saveinmed-backend -f
# Reiniciar todos os serviços SaveInMed
systemctl restart saveinmed-*
# Verificar healthcheck
curl -s https://api.saveinmed.com.br/health | jq
# Login no registry antes de pull
echo "${SCW_SECRET_KEY}" | podman login rg.fr-par.scw.cloud -u nologin --password-stdin
Como Subir Imagem no Scaleway
Passo a Passo Rápido
- Obter credenciais no console Scaleway → IAM → API Keys
- Configurar variáveis de ambiente (ver abaixo)
- Usar o endpoint de upload da API
Endpoint de Upload
# Upload de imagem de produto
curl -X POST https://api.saveinmed.com.br/v1/products/{productId}/images \
-H "Authorization: Bearer {token}" \
-F "image=@/path/to/image.jpg"
# Upload de documento KYC
curl -X POST https://api.saveinmed.com.br/v1/companies/documents \
-H "Authorization: Bearer {token}" \
-F "document=@/path/to/license.pdf" \
-F "type=license"
Response
{
"url": "https://s3.fr-par.scw.cloud/saveinmed-uploads/products/abc123/main.webp"
}
Scaleway Object Storage
O Scaleway Object Storage é um serviço de armazenamento de objetos compatível com a API S3 da AWS. Usamos este serviço para:
- Imagens de produtos
- Documentos KYC (licenças, CNPJs)
- Fotos de perfil
- Comprovantes de entrega
Configuração
1. Variáveis de Ambiente
# .env
SCW_ACCESS_KEY=SCWxxxxxxxxxxxxxxxxx
SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
SCW_REGION=fr-par # ou nl-ams, pl-waw
SCW_BUCKET_NAME=saveinmed-uploads
SCW_ENDPOINT=https://s3.fr-par.scw.cloud
2. Estrutura de Buckets
saveinmed-uploads/
├── products/ # Imagens de produtos
│ └── {product_id}/
│ ├── main.webp
│ └── thumb.webp
├── documents/ # Documentos KYC
│ └── {company_id}/
│ ├── cnpj.pdf
│ └── license.pdf
├── profiles/ # Fotos de perfil
│ └── {user_id}.webp
└── shipments/ # Comprovantes de entrega
└── {shipment_id}/
└── proof.webp
Implementação Backend (Go)
1. Dependências
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/s3
go get github.com/aws/aws-sdk-go-v2/credentials
2. Cliente S3 (internal/storage/s3.go)
package storage
import (
"context"
"fmt"
"io"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type S3Client struct {
client *s3.Client
bucketName string
endpoint string
}
func NewS3Client() (*S3Client, error) {
region := os.Getenv("SCW_REGION")
endpoint := os.Getenv("SCW_ENDPOINT")
accessKey := os.Getenv("SCW_ACCESS_KEY")
secretKey := os.Getenv("SCW_SECRET_KEY")
bucketName := os.Getenv("SCW_BUCKET_NAME")
resolver := aws.EndpointResolverWithOptionsFunc(
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{URL: endpoint}, nil
},
)
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithEndpointResolverWithOptions(resolver),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true // Required for Scaleway
})
return &S3Client{
client: client,
bucketName: bucketName,
endpoint: endpoint,
}, nil
}
// Upload envia um arquivo para o bucket
func (c *S3Client) Upload(ctx context.Context, key string, reader io.Reader, contentType string) (string, error) {
_, err := c.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(c.bucketName),
Key: aws.String(key),
Body: reader,
ContentType: aws.String(contentType),
ACL: "public-read", // ou "private" para documentos sensíveis
})
if err != nil {
return "", fmt.Errorf("failed to upload: %w", err)
}
url := fmt.Sprintf("%s/%s/%s", c.endpoint, c.bucketName, key)
return url, nil
}
// GetPresignedURL gera URL temporária para download
func (c *S3Client) GetPresignedURL(ctx context.Context, key string, expiration time.Duration) (string, error) {
presigner := s3.NewPresignClient(c.client)
req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucketName),
Key: aws.String(key),
}, s3.WithPresignExpires(expiration))
if err != nil {
return "", err
}
return req.URL, nil
}
// Delete remove um arquivo do bucket
func (c *S3Client) Delete(ctx context.Context, key string) error {
_, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(c.bucketName),
Key: aws.String(key),
})
return err
}
3. Handler de Upload (internal/http/handler/upload_handler.go)
package handler
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/google/uuid"
)
const maxUploadSize = 10 << 20 // 10MB
var allowedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
"application/pdf": true,
}
func (h *Handler) UploadProductImage(w http.ResponseWriter, r *http.Request) {
// Limitar tamanho
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "Arquivo muito grande (max 10MB)", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("image")
if err != nil {
http.Error(w, "Arquivo não encontrado", http.StatusBadRequest)
return
}
defer file.Close()
// Validar MIME type
contentType := header.Header.Get("Content-Type")
if !allowedMimeTypes[contentType] {
http.Error(w, "Tipo de arquivo não permitido", http.StatusBadRequest)
return
}
// Gerar key única
productID := r.PathValue("productId")
ext := filepath.Ext(header.Filename)
key := fmt.Sprintf("products/%s/%s%s", productID, uuid.New().String(), ext)
// Upload
url, err := h.storage.Upload(r.Context(), key, file, contentType)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"url": url})
}
Implementação Frontend (TypeScript)
1. Serviço de Upload (src/services/storageService.ts)
import { apiClient } from './apiClient'
interface UploadResponse {
url: string
}
export const storageService = {
// Upload de imagem de produto
uploadProductImage: async (productId: string, file: File): Promise<string> => {
const formData = new FormData()
formData.append('image', file)
const response = await apiClient.post<UploadResponse>(
`/v1/products/${productId}/images`,
formData
)
return response.url
},
// Upload de documento KYC
uploadDocument: async (file: File, type: 'cnpj' | 'license' | 'other'): Promise<string> => {
const formData = new FormData()
formData.append('document', file)
formData.append('type', type)
const response = await apiClient.post<UploadResponse>(
'/v1/companies/documents',
formData
)
return response.url
},
// Upload com preview e progresso
uploadWithProgress: async (
endpoint: string,
file: File,
onProgress: (percent: number) => void
): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText)
resolve(response.url)
} else {
reject(new Error('Upload failed'))
}
})
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
xhr.open('POST', `${import.meta.env.VITE_API_URL}${endpoint}`)
xhr.setRequestHeader('Authorization', `Bearer ${localStorage.getItem('token')}`)
xhr.send(formData)
})
}
}
2. Componente de Upload (src/components/ImageUpload.tsx)
import { useState, useRef } from 'react'
import { storageService } from '../services/storageService'
interface ImageUploadProps {
productId: string
onUploadComplete: (url: string) => void
}
export function ImageUpload({ productId, onUploadComplete }: ImageUploadProps) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [preview, setPreview] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validações
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
alert('Apenas JPEG, PNG ou WebP são permitidos')
return
}
if (file.size > 10 * 1024 * 1024) {
alert('Máximo 10MB')
return
}
// Preview local
setPreview(URL.createObjectURL(file))
setUploading(true)
try {
const url = await storageService.uploadWithProgress(
`/v1/products/${productId}/images`,
file,
setProgress
)
onUploadComplete(url)
} catch (err) {
console.error('Upload failed:', err)
alert('Falha no upload')
} finally {
setUploading(false)
setProgress(0)
}
}
return (
<div className="upload-container">
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
hidden
/>
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
>
{uploading ? `Enviando ${progress}%` : '📤 Enviar Imagem'}
</button>
{preview && (
<img src={preview} alt="Preview" className="upload-preview" />
)}
</div>
)
}
Melhores Práticas
1. Segurança
- ✅ Validar MIME type no backend (não confiar no frontend)
- ✅ Limitar tamanho máximo de upload (10MB recomendado)
- ✅ Usar URLs presignadas para documentos sensíveis
- ✅ Verificar autenticação antes de permitir upload
- ✅ Sanitizar nomes de arquivos (usar UUID)
2. Performance
- ✅ Compressão de imagens antes do upload (client-side)
- ✅ Gerar thumbnails automaticamente
- ✅ Usar WebP como formato preferencial
- ✅ CDN em produção para distribuição global
3. Organização
# Estrutura de keys recomendada
{bucket}/{tipo}/{entity_id}/{arquivo}
# Exemplos:
products/abc123/main.webp
documents/company456/license.pdf
profiles/user789.webp
4. Custo
- ✅ Lifecycle rules para limpar arquivos antigos
- ✅ Storage class adequado (Standard vs Glacier-like)
- ✅ Monitorar uso para evitar surpresas
Configuração do Bucket (Console Scaleway)
- Acesse Object Storage no console Scaleway
- Clique em Create a bucket
- Nome:
saveinmed-uploads - Região:
fr-par(ou mais próxima) - Visibility: Private (acesso via API)
- Configure CORS:
[
{
"AllowedOrigins": ["https://saveinmed.com.br", "http://localhost:5173"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
Troubleshooting
Erro: "Access Denied"
- Verifique as credenciais
SCW_ACCESS_KEYeSCW_SECRET_KEY - Confirme que as permissões do bucket estão corretas
Erro: "CORS Policy"
- Configure CORS no bucket (ver acima)
- Adicione o domínio do frontend na lista de origens permitidas
Upload lento
- Use upload multipart para arquivos > 5MB
- Considere compressão client-side para imagens