Deploy Skill — Production Deployment Patterns
Triết lý: If it doesn't work locally, it won't work in production. Local build pass + env check + migration ready = minimum bar before touching production. Source: distilled từ Commerce Platform production patterns (Railway + Vercel + Neon).
1. AUTO-TRIGGER
DÙNG khi:
✓ User nói: deploy, release, ship, push to production, go live
✓ User mention platform: Railway, Vercel, Render, Heroku, Fly.io, Hetzner, VPS
✓ User mention artifact: Dockerfile, docker-compose.yml, .env, vercel.json
✓ User mention problem: "build failed", "deployment failed", "502", "cold start", "rollback"
✓ User mention config: DATABASE_URL, CORS production, environment variables, port conflict
✓ User nói: "CI/CD", "GitHub Actions", "health check", "env vars"
KHÔNG dùng khi:
✗ Chỉ local dev setup (dùng feature-dev skill)
✗ Code review (dùng code-review skill)
✗ Database schema design (dùng backend-fastapi skill)
2. Deploy Philosophy
Nguyên tắc cốt lõi:
LOCAL FIRST. ALWAYS.
Trước khi deploy bất cứ thứ gì:
□ npm run build (hoặc tsc --noEmit) PASS
□ python -m py_compile main.py PASS
□ Alembic migration đã commit
□ .env.example cập nhật
□ CORS origins updated
□ Health check endpoint hoạt động
"Works on my machine" không phải excuse. Fix local trước.
Deploy = 3 gates:
GATE 1: LOCAL BUILD PASS
Backend: python -c "from app.api.v1 import api_router" + py_compile
Admin: cd web-admin && npm run build
Store: cd web-storefront && npm run build
→ Tất cả pass → mới được sang gate 2
GATE 2: ENV & CONFIG CHECK
□ Tất cả env vars trong dashboard (Railway/Vercel)
□ CORS_ORIGINS include production frontend URLs
□ DATABASE_URL trỏ production (KHÔNG local)
□ Secret keys là production values (không test keys)
GATE 3: MIGRATION READY
□ alembic upgrade head chạy được
□ Migration không phá data hiện tại
□ Backup taken nếu migration có DROP/ALTER COLUMN
3. Environment Variables Rules
NEVER hardcode. ALWAYS via env vars.
# SAI — hardcode secret
SECRET_KEY = "my-secret-key-123"
DATABASE_URL = "postgresql://user:pass@localhost/db"
GROQ_API_KEY = "gsk_abc123"
# ĐÚNG — Pydantic Settings pattern
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SECRET_KEY: str
DATABASE_URL: str
REDIS_URL: str = "redis://localhost:6379"
GROQ_API_KEY: str = ""
GEMINI_API_KEY: str = ""
CORS_ORIGINS: list[str] = ["http://localhost:3001"]
model_config = SettingsConfig(env_file=".env")
settings = Settings()
.env.example — document EVERYTHING:
# Core
SECRET_KEY=your-secret-key-here-min-32-chars
DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dbname
DATABASE_URL_SYNC=postgresql://user:pass@host:5432/dbname
# Cache
REDIS_URL=redis://localhost:6379
# AI providers
GROQ_API_KEY=gsk_...
GEMINI_API_KEY=AIza...
DEEPSEEK_API_KEY=sk-...
# Storage
CLOUDINARY_CLOUD_NAME=your-cloud
CLOUDINARY_API_KEY=your-key
CLOUDINARY_API_SECRET=your-secret
# Payments
VNPAY_TMN_CODE=your-tmn
VNPAY_HASH_SECRET=your-secret
MOMO_PARTNER_CODE=your-code
MOMO_ACCESS_KEY=your-key
MOMO_SECRET_KEY=your-secret
# Shipping
GHN_TOKEN=your-token
GHN_SHOP_ID=your-shop-id
# Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your@email.com
SMTP_PASS=your-app-password
# CORS (comma-separated)
CORS_ORIGINS=https://sonhai-admin.vercel.app,https://sonhai-store.vercel.app
Secrets rotation strategy:
Khi nào rotate secrets:
□ Developer rời team
□ Suspected leak (check git history: git log --all -p | grep "SECRET")
□ Platform breach notification
□ Quarterly rotation cho payment secrets
Cách rotate không downtime:
1. Tạo secret mới trong platform dashboard
2. Update code để accept BOTH old + new (grace period)
3. Deploy
4. Revoke old secret
5. Remove old secret fallback code
6. Deploy lại
4. Port Management
Commerce Platform port map (KHÔNG conflict):
Dynamic Pricing (project khác): 8000 ← KHÔNG ĐƯỢC DÙNG
Commerce Backend (FastAPI): 8001
Commerce Admin (React): 3001
Commerce Storefront (React): 3002
MONII Backend: 8002
MONII Frontend: 3003
Vite default (tránh dùng): 5173
Lý do quan trọng:
# Chạy local đúng port
uvicorn main:app --reload --port 8001
npm run dev -- --port 3001 # web-admin
npm run dev -- --port 3002 # web-storefront
# Kiểm tra port có đang dùng không
lsof -i :8001
lsof -i :3001
# Kill process đang giữ port
kill -9 $(lsof -t -i:8001)
Production không có port conflict — Railway/Vercel tự assign từ $PORT env var:
# Backend: dùng $PORT (Railway set tự động)
CMD = ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT}"]
5. Railway Deployment
Setup lần đầu:
1. railway.app → New Project → Deploy from GitHub
2. Connect repo → chọn branch main
3. Set Root Directory: backend
4. Add PostgreSQL plugin: + New → Database → Add PostgreSQL
5. Add Redis plugin: + New → Database → Add Redis
6. Set env vars (Variables tab):
- DATABASE_URL → ${{Postgres.DATABASE_URL}} (internal, Railway injects)
- DATABASE_URL_SYNC → dùng external URL cho Alembic
- SECRET_KEY → generate: openssl rand -hex 32
- REDIS_URL → ${{Redis.REDIS_URL}}
- CORS_ORIGINS → https://sonhai-admin.vercel.app,https://sonhai-store.vercel.app
- + tất cả AI keys, payment keys
7. Settings → Health Check Path: /health
8. Settings → Restart Policy: Always
Dockerfile pattern cho Railway:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Railway injects $PORT
CMD sh -c "uvicorn main:app --host 0.0.0.0 --port ${PORT}"
DATABASE_URL — internal vs external:
# Internal (dùng trong Railway app — nhanh hơn, không tính bandwidth)
DATABASE_URL=postgresql+asyncpg://postgres:pass@postgres.railway.internal:5432/railway
# External (dùng local để chạy Alembic migration hoặc debug)
DATABASE_URL=postgresql+asyncpg://postgres:pass@roundhouse.proxy.rlwy.net:PORT/railway
# asyncpg (async SQLAlchemy) vs psycopg2 (sync Alembic)
# → asyncpg: postgresql+asyncpg://...
# → psycopg2/Alembic: postgresql://... (KHÔNG có +asyncpg)
# Set DATABASE_URL_SYNC riêng cho Alembic
Connection pool settings cho Railway Postgres:
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=5,
max_overflow=10,
pool_recycle=180,
pool_pre_ping=True,
connect_args={
"statement_cache_size": 0, # BẮT BUỘC cho Railway connection pooler
"server_settings": {"application_name": "commerce-backend"},
},
)
Rollback Railway deployment:
Railway dashboard → Deployments tab
→ Click deployment muốn rollback về
→ "Redeploy" button
→ Confirm
→ Previous deployment active trong ~30s
Auto-deploy from GitHub:
Railway tự deploy khi push to connected branch.
Để deploy manually: railway up (CLI)
Để deploy specific commit: không support — dùng git push với commit đó là HEAD
6. Vercel Deployment
Setup lần đầu:
1. vercel.com → New Project → Import Git Repository
2. Framework Preset: Vite
3. Root Directory: web-admin (hoặc web-storefront)
4. Build Command: npm run build
5. Output Directory: dist
6. Set env vars: VITE_API_URL=https://your-railway-backend.up.railway.app
7. Deploy
vercel.json — bắt buộc cho SPA + API proxy:
{
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://commerce-platform-production-f1e1.up.railway.app/api/:path*"
},
{
"source": "/((?!api/).*)",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" }
]
}
]
}
Deploy commands:
# Preview deploy (không affect production)
npx vercel
# Production deploy
npx vercel --prod --yes
# Assign custom alias sau khi deploy
npx vercel alias <deployment-url> sonhai-admin.vercel.app
Deployment Protection — phải disable nếu muốn public:
Vercel Dashboard → Settings → Deployment Protection
→ Vercel Authentication: Disabled
→ Password Protection: Disabled
→ Save
Env vars tại Vercel:
# Không dùng .env trong Vercel — set trong dashboard hoặc CLI
vercel env add VITE_API_URL production
vercel env add VITE_API_URL preview
# Pull về local để debug
vercel env pull .env.local
7. Database Migrations on Deploy
LUÔN LUÔN: migration TRƯỚC deploy, KHÔNG sau.
Thứ tự bắt buộc:
1. Deploy migration (alembic upgrade head)
2. Verify migration thành công
3. Deploy new application code
4. Verify health check pass
Sai:
1. Deploy new code ← code mới expect column chưa có → 500 errors
2. Run migration
Tạo migration đúng cách:
# Generate migration từ model changes
alembic revision --autogenerate -m "add shipping_tracking to orders"
# Kiểm tra migration file TRƯỚC khi commit
cat alembic/versions/<generated_file>.py
# Test locally
alembic upgrade head
alembic downgrade -1 # verify rollback works
alembic upgrade head # go back up
# Commit migration file
git add alembic/versions/<file>.py
git commit -m "migration: add shipping_tracking to orders"
2-step NOT NULL column pattern (KHÔNG phá production):
# SAIIII — phá migration nếu table có data
op.add_column('products', sa.Column('weight_kg', sa.Numeric(8,2), nullable=False))
# ĐÚNG — 2 bước
# Step 1: add nullable, với server_default
op.add_column('products', sa.Column('weight_kg', sa.Numeric(8,2), nullable=True, server_default='0'))
# Step 2 (migration riêng, sau khi deploy step 1):
op.alter_column('products', 'weight_kg', nullable=False)
op.alter_column('products', 'weight_kg', server_default=None)
Chạy migration on Railway:
# Cách 1: Railway CLI (recommended)
railway run alembic upgrade head
# Cách 2: Local với external DATABASE_URL
DATABASE_URL_SYNC=postgresql://user:pass@external-host:port/db alembic upgrade head
# Cách 3: Trong Dockerfile startup (rủi ro — nếu fail thì app không start)
CMD sh -c "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port ${PORT}"
8. CORS in Production
Whitelist explicit origins. KHÔNG dùng * cho production.
# SAI — accept mọi origin
app.add_middleware(CORSMiddleware, allow_origins=["*"])
# ĐÚNG — explicit whitelist
from app.core.config import settings
origins = [origin.strip() for origin in settings.CORS_ORIGINS.split(",")]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Env var:
# KHÔNG có trailing slash — common mistake!
CORS_ORIGINS=https://sonhai-admin.vercel.app,https://sonhai-store.vercel.app
# SAI — trailing slash gây CORS fail
CORS_ORIGINS=https://sonhai-admin.vercel.app/,https://sonhai-store.vercel.app/
Checklist CORS:
□ Admin frontend URL (web-admin Vercel deployment)
□ Storefront frontend URL (web-storefront Vercel deployment)
□ Mobile app (nếu có — thường không cần CORS cho native)
□ localhost:3001, localhost:3002 cho local dev (dev env only)
□ Staging URL nếu có (staging.sonhai-admin.vercel.app)
9. Health Checks
Implement /health endpoint — Railway dùng nó để biết app ready:
from fastapi import APIRouter
from sqlalchemy import text
router = APIRouter()
@router.get("/health")
async def health_check(db: AsyncSession = Depends(get_db)):
"""Railway health check. Must return 200 for app to receive traffic."""
try:
# Check DB connectivity
await db.execute(text("SELECT 1"))
# Check Redis connectivity (optional)
# await redis.ping()
return {
"status": "healthy",
"db": "connected",
"timestamp": datetime.utcnow().isoformat(),
}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Unhealthy: {str(e)}")
@router.get("/health/detailed")
async def health_detailed():
"""Detailed metrics — không expose tới public, dùng Railway internal."""
return {
"version": settings.APP_VERSION,
"db_pool_size": engine.pool.size(),
"db_checked_out": engine.pool.checkedout(),
...
}
Railway health check config:
Railway Dashboard → Service Settings → Health Check
Path: /health
Timeout: 30s (đủ để DB connection khởi động)
Railway chờ 200 response trước khi route traffic sang deployment mới.
Nếu không config → Railway deploy ngay (risky khi DB chưa ready).
10. Zero-Downtime Deploy Pattern
Railway làm gì khi deploy:
1. Build new Docker image
2. Start new container (chạy song song với old)
3. Health check new container (GET /health → expect 200)
4. Khi new container healthy → route traffic sang
5. Send SIGTERM to old container (graceful shutdown)
6. Old container có 30s để finish in-flight requests
7. Old container killed
SIGTERM handler — drain connections gracefully:
import signal
import asyncio
shutdown_event = asyncio.Event()
def handle_sigterm(signum, frame):
"""Railway sends SIGTERM before killing container."""
shutdown_event.set()
signal.signal(signal.SIGTERM, handle_sigterm)
@app.on_event("shutdown")
async def shutdown():
"""Wait for in-flight requests before closing DB connections."""
await asyncio.sleep(2) # drain in-flight requests
await engine.dispose()
Connection draining:
# uvicorn timeout-graceful-shutdown (seconds)
# Set trong Railway: uvicorn --timeout-graceful-shutdown 30
CMD sh -c "uvicorn main:app --host 0.0.0.0 --port ${PORT} --timeout-graceful-shutdown 30"
11. Rollback
Khi nào rollback ngay:
□ Error rate > 5% sau deploy (Railway logs → filter 5xx)
□ Health check failing (app restart loop)
□ Auth endpoints returning 500
□ Payment endpoints broken (rollback NGAY, không chờ)
□ DB migration gây data corruption
Rollback Railway (application):
Railway Dashboard → Deployments tab
→ Tìm deployment trước đó (màu xanh = active)
→ Click previous successful deployment
→ "Redeploy" button
→ ~30s để route traffic sang
Rollback DB migration:
# Xem current revision
alembic current
# Xem history
alembic history --verbose
# Downgrade 1 step
alembic downgrade -1
# Downgrade về specific revision
alembic downgrade abc123de
# KHÔNG thể rollback nếu migration drop column + data đã mất
# → Đó là lý do phải backup trước mọi destructive migration
Backup trước destructive migration:
# Railway PostgreSQL backup
railway run pg_dump -Fc > backup_$(date +%Y%m%d_%H%M%S).dump
# Restore nếu cần
railway run pg_restore -d $DATABASE_URL backup_file.dump
12. Post-Deploy Verification
Smoke test ngay sau deploy — không chờ user report:
□ Open production URL → page load không trắng
□ Login với test account → success
□ List products → data hiện
□ Create test product → tạo được, hiện trong list
□ Create test order → tạo được
□ Health check URL trả 200: curl https://backend-url/health
□ Check Railway logs: không có ERROR 500
□ Check Vercel function logs: không có exception
Monitor sau deploy:
# Watch Railway logs real-time
railway logs --follow
# Filter chỉ errors
railway logs | grep -E "ERROR|Exception|Traceback"
# Check 5xx rate (Railway Metrics tab → HTTP → Status Codes)
# Nếu 5xx > 1% → investigate ngay
Alert nếu 5xx spike:
# Middleware log 5xx với đủ context để debug
@app.middleware("http")
async def log_5xx(request: Request, call_next):
response = await call_next(request)
if response.status_code >= 500:
logger.error(
f"5xx: {request.method} {request.url.path} → {response.status_code}",
extra={"shop_id": request.state.shop_id if hasattr(request.state, "shop_id") else None}
)
return response
References
references/railway-vercel.md— Railway + Vercel setup chi tiếtreferences/pre-deploy-checklist.md— Checklist đầy đủ trước mỗi deploy- Commerce Platform CLAUDE.md → PRODUCTION DEPLOYMENT section
- Railway docs: docs.railway.app
- Vercel docs: vercel.com/docs