Até aqui construímos APIs que retornam JSON. Mas muitas aplicações precisam também servir HTML — painéis administrativos, relatórios, páginas de erro customizadas. Jinja2 é o motor de templates padrão do Flask e do FastAPI, e dominar seus recursos abre essa porta. Na segunda parte do artigo veremos como empacotar toda a aplicação em um container Docker — o padrão da indústria para deploy consistente e reproduzível.
Jinja2: Motor de Templates
pip install jinja2
Sintaxe Fundamental
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>{% block titulo %}Sistema Escolar{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/estilo.css') }}">
</head>
<body>
<nav>
<a href="{{ url_for('inicio') }}">Início</a>
<a href="{{ url_for('alunos.listar') }}">Alunos</a>
{% if usuario %}
<span>Olá, {{ usuario.nome }}!</span>
<a href="{{ url_for('auth.logout') }}">Sair</a>
{% else %}
<a href="{{ url_for('auth.login') }}">Entrar</a>
{% endif %}
</nav>
<main>
{% with mensagens = get_flashed_messages(with_categories=true) %}
{% for categoria, mensagem in mensagens %}
<div class="alerta alerta-{{ categoria }}">{{ mensagem }}</div>
{% endfor %}
{% endwith %}
{% block conteudo %}{% endblock %}
</main>
<footer>
<p>© {{ ano }} Sistema Escolar</p>
</footer>
</body>
</html>
<!-- templates/alunos/lista.html -->
{% extends "base.html" %}
{% block titulo %}Lista de Alunos{% endblock %}
{% block conteudo %}
<h1>Alunos</h1>
<p>Total: {{ alunos | length }}</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Nome</th>
<th>Nota</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for aluno in alunos %}
<tr class="{{ 'aprovado' if aluno.nota >= 6 else 'reprovado' }}">
<td>{{ loop.index }}</td>
<td>{{ aluno.nome | title }}</td>
<td>{{ "%.1f" | format(aluno.nota) }}</td>
<td>
{% if aluno.nota >= 9 %}
Excelente
{% elif aluno.nota >= 7 %}
Bom
{% elif aluno.nota >= 6 %}
Regular
{% else %}
Reprovado
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="4">Nenhum aluno cadastrado.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
Filtros e Funções Jinja2
<!-- Filtros embutidos -->
{{ nome | upper }} <!-- RICARDO -->
{{ nome | lower }} <!-- ricardo -->
{{ nome | title }} <!-- Ricardo Matos -->
{{ nome | truncate(20) }} <!-- só corta acima de 20 caracteres, e termina em "..." -->
{{ lista | length }} <!-- 5 -->
{{ lista | join(", ") }} <!-- a, b, c -->
{{ valor | round(2) }} <!-- 9.5 — round não fixa casas; para isso, "%.2f"|format(valor) -->
{{ texto | replace("a", "b") }}
{{ html | safe }} <!-- renderiza HTML sem escapar -->
{{ data | strftime("%d/%m/%Y") }} <!-- filtro customizado -->
<!-- Testes -->
{% if valor is none %}
{% if lista is iterable %}
{% if numero is divisibleby(2) %}
{% if texto is defined %}
Filtros Customizados
# app.py — registrando filtros customizados no Flask
from datetime import datetime
@app.template_filter("data_br")
def filtro_data_br(valor):
if isinstance(valor, str):
valor = datetime.fromisoformat(valor)
return valor.strftime("%d/%m/%Y")
@app.template_filter("moeda")
def filtro_moeda(valor):
return f"R$ {valor:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
@app.template_filter("plural")
def filtro_plural(numero, singular, plural):
return singular if numero == 1 else plural
<!-- Usando filtros customizados -->
{{ aluno.criado_em | data_br }} <!-- 15/03/2024 -->
{{ produto.preco | moeda }} <!-- R$ 1.299,90 -->
{{ total }} {{ total | plural("aluno", "alunos") }} <!-- 1 aluno / 5 alunos -->
Templates com Flask
from flask import Flask, render_template, request, redirect, url_for, flash
from datetime import datetime
app = Flask(__name__)
app.secret_key = "dev-secret"
alunos_dados = [
{"id": 1, "nome": "Ana Silva", "nota": 9.5},
{"id": 2, "nome": "Bruno Costa", "nota": 7.0},
{"id": 3, "nome": "Carla Souza", "nota": 5.5},
]
@app.context_processor
def variaveis_globais():
"""Variáveis disponíveis em todos os templates."""
return {
"ano": datetime.now().year,
"usuario": None # em produção: sessão do usuário logado
}
@app.route("/")
def inicio():
return render_template("inicio.html")
@app.route("/alunos")
def listar_alunos():
nota_min = request.args.get("nota_min", 0, type=float)
filtrados = [a for a in alunos_dados if a["nota"] >= nota_min]
return render_template(
"alunos/lista.html",
alunos=filtrados,
nota_min=nota_min
)
@app.route("/alunos/novo", methods=["GET", "POST"])
def novo_aluno():
if request.method == "POST":
nome = request.form.get("nome", "").strip()
# type=float devolveria None para "8,5", a vírgula que todo brasileiro digita
try:
nota = float(request.form.get("nota", "").replace(",", "."))
except ValueError:
nota = None
if not nome or nota is None:
flash("Nome e nota são obrigatórios.", "erro")
return render_template("alunos/form.html")
aluno = {"id": len(alunos_dados) + 1, "nome": nome, "nota": nota}
alunos_dados.append(aluno)
flash(f"Aluno '{nome}' cadastrado com sucesso!", "sucesso")
return redirect(url_for("listar_alunos"))
return render_template("alunos/form.html")
Templates com FastAPI
pip install jinja2 python-multipart
from datetime import datetime
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")
_alunos: dict = {} # banco em memória, como nos artigos anteriores
@app.get("/", response_class=HTMLResponse)
async def inicio(request: Request):
# request vem primeiro, fora do dicionário — a assinatura antiga dá 500
return templates.TemplateResponse(
request,
"inicio.html",
{"titulo": "Sistema Escolar"}
)
@app.get("/alunos", response_class=HTMLResponse)
async def listar_alunos(request: Request):
return templates.TemplateResponse(
request,
"alunos/lista.html",
{
"alunos": list(_alunos.values()),
"ano": datetime.now().year
}
)
@app.post("/alunos/novo")
async def criar_aluno_form(
request: Request,
nome: str = Form(...),
email: str = Form(...),
nota: float = Form(...)
):
# Processa o formulário e redireciona
return RedirectResponse(url="/alunos", status_code=303)
O request saiu do dicionário de contexto e passou a ser o primeiro argumento de TemplateResponse. A forma antiga, que ainda aparece em muito tutorial, foi removida no Starlette 1.x e hoje responde 500 com um erro que não aponta a causa: TypeError: cannot use 'tuple' as a dict key. E o base.html do começo do artigo é de Flask: get_flashed_messages não existe no FastAPI, e o url_for de arquivo estático do Starlette recebe path=, não filename=. Um mesmo template serve aos dois frameworks só se evitar essas funções.
Docker: Containerizando a Aplicação
Docker empacota a aplicação com todas as suas dependências em uma unidade isolada — o container — que roda de forma idêntica em qualquer ambiente.
# Instalação — https://docs.docker.com/get-docker/
docker --version
docker compose version
Dockerfile para FastAPI
# Dockerfile
FROM python:3.12-slim
# Variáveis de ambiente
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Diretório de trabalho
WORKDIR /app
# Instala dependências primeiro (aproveitando cache de camadas)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copia o código
COPY . .
# Usuário não-root — boa prática de segurança
RUN adduser --disabled-password --gecos "" appuser && \
chown -R appuser:appuser /app
USER appuser
# Porta exposta
EXPOSE 8000
# Comando de inicialização
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# .dockerignore
__pycache__/
*.pyc
*.pyo
.env
.git/
.gitignore
venv/
.venv/
tests/
*.md
.pytest_cache/
# Construindo a imagem
docker build -t escola-api:1.0.0 .
# Rodando o container
docker run -d \
--name escola-api \
-p 8000:8000 \
-e SECRET_KEY=minha-chave-secreta \
-e DATABASE_URL=postgresql://user:senha@host/db \
escola-api:1.0.0
# Verificando logs
docker logs -f escola-api
# Parando e removendo
docker stop escola-api
docker rm escola-api
Docker Compose: Orquestrando Múltiplos Serviços
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: escola-api
ports:
- "8000:8000"
environment:
- SECRET_KEY=${SECRET_KEY}
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/escola
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 # o redis exige senha
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
- ./logs:/app/logs
restart: unless-stopped
networks:
- escola-net
db:
image: postgres:16-alpine
container_name: escola-db
environment:
- POSTGRES_DB=escola
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- escola-net
redis:
image: redis:7-alpine
container_name: escola-redis
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- escola-net
nginx:
image: nginx:alpine
container_name: escola-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./static:/app/static:ro # o nginx só serve o que existe no container DELE
depends_on:
- api
networks:
- escola-net
volumes:
postgres_data:
redis_data:
networks:
escola-net:
driver: bridge
# nginx/nginx.conf
upstream api {
server api:8000;
}
server {
listen 80;
server_name meusite.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name meusite.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
location / {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location /static {
alias /app/static;
expires 7d;
}
}
# Arquivo .env para docker-compose
SECRET_KEY=sua-chave-super-secreta-aqui
DB_PASSWORD=senha-do-postgres
REDIS_PASSWORD=senha-do-redis
# Comandos docker compose
docker compose up -d # sobe todos os serviços em background
docker compose down # para e remove containers
docker compose logs -f api # logs em tempo real do serviço api
docker compose ps # status dos serviços
docker compose exec api bash # acessa o container da api
docker compose build --no-cache # reconstrói as imagens
Dois erros do compose passam despercebidos até a aplicação precisar do serviço. O Redis sobe com --requirepass, e uma REDIS_URL sem a senha faz toda operação falhar com NOAUTH — daí o :${REDIS_PASSWORD}@ na URL. E o alias /app/static do nginx procura a pasta no container do próprio nginx, onde ela não existe: sem o volume ./static:/app/static:ro nele, todo arquivo estático é 404. O campo version no topo foi retirado porque a especificação atual do Compose o ignora.
Dockerfile Multi-stage: Imagem Otimizada
# Dockerfile.prod — imagem de produção otimizada
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Copia apenas as dependências instaladas
COPY --from=builder /install /usr/local
# Copia o código da aplicação
COPY app/ ./app/
COPY templates/ ./templates/
COPY static/ ./static/
# Usuário não-root
RUN adduser --disabled-password --gecos "" appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--workers", "4"]
O ganho do multi-stage depende do que o estágio builder faz. Quando alguma dependência precisa ser compilada — e aí o builder instala build-essential ou as bibliotecas de desenvolvimento do sistema —, compiladores e cabeçalhos ficam para trás, e a imagem final leva só o resultado. Com as duas imagens partindo do mesmo python:3.12-slim e todas as dependências distribuídas como wheel pronta, como aqui, a diferença é pequena: o que se ganha é a separação, não os megabytes.
Health Check e Graceful Shutdown
from fastapi import FastAPI
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
print("Conectando ao banco...")
yield
# Shutdown — roda DEPOIS que o uvicorn termina as requisições em andamento;
# aqui se fecham conexões e pools, não se espera
print("Encerrando conexões...")
app = FastAPI(lifespan=lifespan)
@app.get("/health")
async def health_check():
"""Endpoint para verificação de saúde — usado pelo Docker e load balancer."""
return {
"status": "healthy",
"versao": "1.0.0",
}
@app.get("/ready")
async def readiness_check():
"""Verifica se a aplicação está pronta para receber tráfego."""
try:
# Verifica conexão com banco, cache, etc.
return {"status": "ready"}
except Exception:
from fastapi import HTTPException
raise HTTPException(status_code=503, detail="Serviço indisponível.")
O asyncio.sleep(1) que o exemplo tinha no shutdown não aguardava nada. Medido com uma requisição de 3 segundos em andamento e um SIGTERM no meio dela: o uvicorn terminou a requisição, o cliente recebeu 200, e só então o código após o yield rodou. O lifespan de encerramento serve para fechar pools e conexões. Para o Docker usar o /health, falta ainda uma instrução HEALTHCHECK no Dockerfile ou um healthcheck no compose; a imagem slim não tem curl, então o teste costuma ser um python -c com urllib.request.
O Jinja2 resolve a parte do HTML com três ideias: herança, em que um base.html define os blocos e cada página preenche os seus; controle de fluxo com for e if, inclusive o else do laço para a lista vazia; e filtros, que formatam valores sem levar lógica para dentro do template. Os filtros embutidos fazem menos do que o nome sugere — round não fixa casas decimais, truncate só age acima do limite —, e um filtro customizado devolve exatamente o que a função retorna. O que muda de um framework para outro está em volta do template: no Flask, render_template e as mensagens flash; no FastAPI, Jinja2Templates, com o request como primeiro argumento da resposta.
O Docker empacota a aplicação com suas dependências numa imagem que roda igual em qualquer lugar, desde que o Dockerfile aproveite o cache de camadas copiando o requirements.txt antes do código e rode com um usuário sem privilégios. O Compose junta API, banco, cache e proxy numa rede própria, e é nele que aparecem os erros de integração: a senha que o Redis exige e a URL não leva, o nginx que procura estáticos num container que não os tem. O multi-stage separa a compilação da execução, e os endpoints de saúde dizem ao orquestrador se o container está vivo e pronto — o encerramento limpo, o uvicorn já faz.
Fontes e leituras recomendadas
- Jinja2 — documentação oficial — https://jinja.palletsprojects.com/en/3.1.x/
- FastAPI — templates — https://fastapi.tiangolo.com/advanced/templates/
- Docker — documentação oficial — https://docs.docker.com/
- Docker Compose — referência — https://docs.docker.com/compose/compose-file/
- Nginx como proxy reverso — https://nginx.org/en/docs/beginners_guide.html
- Python Docker official images — https://hub.docker.com/_/python
- KANE, Sean; MATTHIAS, Karl. Docker: Up and Running. 3. ed. O'Reilly Media, 2023. — guia completo de Docker para desenvolvimento e produção.
- GRINBERG, Miguel. Flask Web Development. 2. ed. O'Reilly Media, 2018. Cap. 3 — templates com Jinja2 no contexto do Flask.
Exercícios
Exercício 1
Um relatório em Jinja2 mostra as médias com {{ media | round(2) }}, e o cliente reclama que a coluna fica desalinhada: aparecem 9.5, 7.25 e 8.0 lado a lado. Na mesma tela, {{ total | plural("aluno", "alunos") }} exibe só "alunos", sem o número. Explique os dois comportamentos e corrija o template.
Ver resposta
✓ Resposta: O filtro round arredonda o valor, não a sua apresentação: devolve um número, e o número 9.50 é impresso como 9.5 — medido, {{ 9.5 | round(2) }} renderiza 9.5. Para fixar as casas é preciso formatar como texto: {{ "%.2f" | format(media) }} ou, com o separador brasileiro, um filtro próprio como o moeda do artigo, que transformou 1299.9 em R$ 1.299,90. Já o plural faz exatamente o que a função diz: recebe o número, compara com 1 e devolve uma das duas palavras — medido, com total = 5, a saída foi só alunos. O número não aparece porque ninguém o imprimiu; o template certo é {{ total }} {{ total | plural("aluno", "alunos") }}. Vale ainda cuidar do zero: em português, "0 alunos" está correto e a função já o trata como plural, mas em outras línguas a regra muda, e para sistemas multilíngues a ferramenta adequada é o ngettext do gettext, que o Jinja2 integra pela extensão i18n.
Exercício 2
Um formulário de cadastro em Flask lê a nota com request.form.get("nota", type=float). Os professores reclamam que o sistema responde "Nome e nota são obrigatórios" mesmo com os dois campos preenchidos — mas só alguns deles, e nem sempre. Os logs não mostram erro. Qual é a causa provável, e como tratar a entrada?
Ver resposta
✓ Resposta: A vírgula decimal. Professores brasileiros digitam 8,5, e float("8,5") levanta ValueError; o type=float do Flask engole a exceção e devolve None, exatamente como se o campo estivesse vazio — medido, o formulário com nota=8,5 produziu None. A mensagem "obrigatórios" é então verdadeira do ponto de vista do código e absurda para quem preencheu. Afeta "só alguns" porque quem digita 8 ou 8.5 passa. A correção tem duas partes. Na leitura, aceitar os dois separadores — float(texto.replace(",", ".")) dentro de um try, como no exemplo corrigido. Na resposta, distinguir campo vazio de campo inválido: "Informe a nota" é uma mensagem, "Nota inválida: use um número de 0 a 10" é outra, e o valor digitado deve voltar para o formulário para ser corrigido. No HTML, um <input type="number" step="0.1" min="0" max="10"> ajuda, porque o navegador normaliza o valor enviado para ponto — mas a validação no servidor continua obrigatória, já que o formulário pode ser enviado por qualquer cliente.
Exercício 3
Uma equipe atualiza as dependências de um painel em FastAPI e todas as páginas HTML passam a responder 500 com TypeError: cannot use 'tuple' as a dict key, enquanto as rotas JSON seguem normais. Ninguém mexeu nos templates, e o traceback termina dentro do Starlette. O que aconteceu, e como encontrar esse tipo de quebra antes do deploy?
Ver resposta
✓ Resposta: O código usa a assinatura antiga, templates.TemplateResponse("pagina.html", {"request": request, ...}). Por várias versões o Starlette aceitou as duas formas, emitindo um aviso de obsolescência na antiga; na série 1.x ela foi removida, e o nome do template passou a ser lido na posição do request, o que produz um erro sem nenhuma relação aparente com a causa. Medido no Starlette 1.7: a forma antiga respondeu 500, e TemplateResponse(request, "pagina.html", {...}), 200. As rotas JSON não são afetadas porque não passam por templates. Encontrar isso antes do deploy depende de duas práticas. A primeira é tratar avisos de obsolescência como erro nos testes — python -W error::DeprecationWarning -m pytest, ou a opção filterwarnings = error do pytest —, porque a remoção foi anunciada por avisos durante versões. A segunda é ter ao menos um teste que renderize cada página HTML com o TestClient e confira o status 200; sem isso, a suíte cobre só a API JSON e fica verde enquanto o painel está fora do ar. E atualizar dependências com versões travadas num arquivo de lock, uma de cada vez, torna óbvio qual delas quebrou.
Exercício 4
O docker compose up sobe os quatro serviços sem erro, o /health responde, mas o cache nunca funciona — toda operação no Redis falha com NOAUTH Authentication required —, e o site carrega sem CSS nem imagens, com 404 em todo /static/.... Os dois arquivos, compose e nginx, foram copiados de um tutorial. Encontre as duas falhas de integração.
Ver resposta
✓ Resposta: A primeira está na combinação de dois trechos do compose que, isolados, parecem certos. O serviço redis sobe com redis-server --requirepass ${REDIS_PASSWORD}, mas a API recebe REDIS_URL=redis://redis:6379/0, sem credencial: a conexão abre, e cada comando é recusado com NOAUTH. A URL precisa levar a senha, redis://:${REDIS_PASSWORD}@redis:6379/0 — os dois-pontos antes dela indicam usuário vazio. A segunda está no nginx: location /static { alias /app/static; } manda o nginx ler os arquivos do seu próprio sistema de arquivos, e /app/static existe só no container da API. Cada container tem o seu disco; o nginx não enxerga o da API. A correção é montar a pasta também no nginx, ./static:/app/static:ro, ou tirar o location /static e deixar a API servir os estáticos pelo StaticFiles, ao custo de desempenho. O /health respondendo mostra por que esses erros escapam: ele só confirma que o processo está de pé. Um endpoint de prontidão que teste de fato o Redis — um PING — e uma checagem de um arquivo estático no teste de fumaça do deploy teriam acusado os dois.
Exercício 5
Para garantir que nenhuma requisição seja cortada nos deploys, um desenvolvedor coloca await asyncio.sleep(5) depois do yield do lifespan, "para dar tempo das requisições terminarem". Os deploys passam a demorar mais, e o problema de requisições cortadas continua aparecendo de vez em quando. Explique por que o sleep não ajuda e onde está o encerramento limpo de verdade.
Ver resposta
✓ Resposta: O código após o yield roda depois, e não durante, a espera pelas requisições. Ao receber SIGTERM, o uvicorn para de aceitar conexões novas, aguarda as requisições em andamento terminarem e só então executa o shutdown do lifespan. Medido com uma requisição de 3 segundos em curso e um SIGTERM no meio: a requisição terminou, o cliente recebeu 200, e a mensagem do shutdown apareceu em seguida. O sleep(5) só atrasa a saída do processo, com nada mais para esperar. Os cortes que continuam têm outras causas. A mais comum é o prazo do orquestrador: o Docker manda SIGTERM e, se o processo não sair em 10 segundos (o padrão de docker stop), manda SIGKILL — e o sleep extra consome esse prazo, piorando a situação. Outra é o sinal não chegar ao uvicorn, quando o CMD está na forma de texto e o processo 1 do container é um shell; a forma de lista, CMD ["uvicorn", ...], evita isso. A terceira é o balanceador continuar mandando tráfego para a instância que está saindo; o endpoint /ready deve passar a responder 503 assim que o encerramento começa, e o --timeout-graceful-shutdown do uvicorn limita quanto tempo esperar por requisições longas.