Contexto
O problema aqui é bastante óbvio — manter um backup de todos os posts sem perder nenhuma informação.
O sistema do blog foi estruturado de forma customizada e, por esse motivo, eu precisava de um sistema de backup compatível com minha arquitetura. Caso ainda não tenham lido sobre a infraestrutura do LearningSea, recomendo os posts anteriores, onde explico em detalhes minhas decisões técnicas.
Meu sistema de backup me fornece uma série de benefícios práticos — granularidade por post (backup e restore), versionamento, texto legível para humanos e a possibilidade de restaurar posts com um único comando. Além disso, ele também ajuda a economizar espaço em disco e reduzir custos.
Alternativas convencionais
Em primeiro lugar, vou explicar por que não utilizei alternativas convencionais de backup. As principais opções eram pg_dump e django-dbbackup.
Ambas seriam bastante confortáveis de utilizar, já que o pg_dump é uma funcionalidade nativa do próprio banco de dados, enquanto o django-dbbackup já se integra ao framework.
Entretanto, as duas alternativas apresentam restrições importantes:
- fazem backup completo do banco de dados toda vez que o comando é executado
- o fluxo de restore é orientado ao snapshot completo do banco de dados, sem granularidade simples para restaurar ou fazer backup de um único post
- o conteúdo não possui uma representação simples e amigável para leitura humana, especialmente em formatos binários ou dumps
.sql - acúmulo de espaço em disco ao longo do tempo
O pg_dump possui um problema ainda maior: ele copiaria apenas as informações do banco de dados. Entretanto, os posts também contêm imagens armazenadas na pasta MEDIA_ROOT do Django. Isso significa que, além do backup do banco, eu ainda precisaria realizar um backup manual da pasta de imagens.
Nesse cenário, o django-dbbackup seria mais viável, já que consegue realizar o backup tanto do banco de dados quanto da pasta de imagens do Django ao mesmo tempo, utilizando um único comando.
Mas as restrições citadas acima continuam existindo — e, no meu contexto, elas são bastante relevantes.
Dito isso, meu sistema de backups resolve todos esses problemas. E a melhor parte é que, na maior parte do tempo, eu nem preciso pensar sobre isso.
Visão geral do sistema de backups
Toda vez que um post é salvo, o sistema automaticamente gera um snapshot completo no filesystem — conteúdo, categoria, tags e todas as imagens relacionadas. Esses snapshots são armazenados em uma estrutura hierárquica de pastas e podem ser restaurados a qualquer momento.
A estrutura é exatamente esta:
backups/
└── post-slug/
├── post.json
├── post.md
├── images/
└── updates/
└── update_YYYY-MM-DD_HH-MM-SS/
├── post.json
├── post.md
└── images/
Versionamento e post.json
No processo de versionamento de um post, existe um detalhe elegante: as imagens não são duplicadas.
O sistema lista as imagens encontradas no post e inclui nos updates subsequentes apenas as novas imagens.
O mesmo acontece durante o processo de restore — o sistema percorre todas as imagens encontradas nas pastas do post e reconstrói o estado completo dele.
O coração do backup é o arquivo post.json, que representa o snapshot completo do post. Esse arquivo contém a estrutura exata de um post no banco de dados.
A principal diferença desse sistema é que o backup é orientado à entidade do post, e não ao estado completo do banco de dados.
Exemplo de um post.json:
{
"title": "LearningSea: um registro do meu aprendizado em engenharia de software",
"slug": "about-learningsea",
"excerpt": "Neste primeiro post, apresento o LearningSea, um blog onde documento meu aprendizado, experimentos e decisões ao desenvolver software.",
"editor_type": "markdown",
"content_md": "...",
"content_html": "...",
"status": "published",
"created_at": "2026-03-28T04:16:16.903208+00:00",
"updated_at": "2026-03-28T04:16:16.991055+00:00",
"category": "Meta",
"tags": [
"LearningSea"
],
"images": []
}
Agora, vale a pena entender a pipeline por dentro desse sistema.
Pipeline
O sistema tem duas pipelines: backup e restore.
Backup
Post é salvo ou atualizado
→ Django dispara o sinal post_save
→ post_saved() captura o sinal
→ chama backup_post(created=True/False)
→ get_image_paths() escaneia o HTML do post procurando tags <img>
→ se é um post novo:
→ copy_images() copia todas as imagens encontradas para backups/<slug>/images/
→ build_post_data() monta todos os campos do post em um dicionário
→ escreve post.json em backups/<slug>/
→ build_frontmatter() constrói o cabeçalho YAML
→ save_markdown_file() escreve post.md em backups/<slug>/
→ se é uma atualização:
→ cria backups/<slug>/updates/update_<timestamp>/
→ get_all_backed_up_images() lê o post.json raiz + todos os post.json de updates anteriores → constrói um conjunto com todas as imagens já salvas
→ filtra as imagens já salvas → mantém apenas as novas
→ copy_images() copia apenas as imagens novas para a pasta do update
→ build_post_data() monta todos os campos do post em um dicionário
→ escreve post.json na pasta do update
→ build_frontmatter() constrói o cabeçalho YAML
→ save_markdown_file() escreve post.md na pasta do update
→ Django dispara o sinal m2m_changed (tags são salvas separadamente)
→ post_tags_changed() captura o sinal
→ verifica a idade do post
→ menos de 5 segundos → sobrescreve o backup inicial (created=True) com as tags corretas
→ 5 segundos ou mais → cria uma nova entrada de update (created=False)
Restore
python manage.py restore_post backups/<slug> [--update X] [--dry-run]
→ handle() executa
→ se --update não foi passado:
→ get_latest_update() escaneia a pasta updates/ → ordena alfabeticamente → retorna o último
→ resolve o json_path
→ update existe → backups/<slug>/updates/<update>/post.json
→ sem update → backups/<slug>/post.json
→ carrega o post.json na memória
→ collect_all_images() percorre images/ raiz + images/ de cada update em ordem cronológica
→ para no update alvo → ignora imagens de updates futuros
→ retorna um dicionário { filename -> caminho completo }
→ exibe um preview do que será restaurado
→ se --dry-run → para aqui, nada é escrito
→ Category.get_or_create() → reutiliza ou cria a categoria
→ Tag.get_or_create() → reutiliza ou cria cada tag
→ Post.update_or_create(slug=...) → atualiza se existe, cria se não existe
→ post.tags.set() + post.save()
→ copia todas as imagens coletadas para MEDIA_ROOT/uploads/
→ Concluído
As pipelines acima já mostram todo o fluxo percorrido pelo sistema. Entretanto, vale entender um pouco mais a fundo algumas partes relevantes.
Principais módulos, responsabilidades e funções
signals.py → reage a eventos de persistência do Django, disparando resize de imagens e pipelines de backup
backup_post.py → realiza o snapshot completo do post
restore_post.py → lê arquivos de backup e reconstrói posts
A seguir apresentarei os módulos. Algumas funções serão omitidas para manter o foco apenas na pipeline principal do sistema.
signals.py
Os Django Signals são um mecanismo de eventos internos do framework que permitem executar funções automaticamente quando determinadas ações acontecem, como salvar um model ou alterar relações many-to-many.
É o mecanismo perfeito para o que eu preciso no sistema de backups — quando um post é salvo, o módulo de backup é chamado automaticamente.
Lista de funções do módulo:
resize_uploaded_imagepost_savedpost_tags_changed
@receiver(post_save, sender=Attachment)
def resize_uploaded_image(sender, instance, created, **kwargs):
if created and instance.file:
resize_image(instance.file.path)
@receiver(post_save, sender=Post)
def post_saved(sender, instance, created, **kwargs):
try:
backup_post(instance, created=created)
except Exception as e:
logger.error(f'Post backup failed for {instance.slug}: {e}')
@receiver(m2m_changed, sender=Post.tags.through)
def post_tags_changed(sender, instance, action, **kwargs):
...
resize_uploaded_image()→ escutapost_savedo modelAttachment(Summernote) — quando uma imagem é salva, chama um módulo auxiliar de redimensionamento de imagens (que utiliza Pillow)post_saved()→ escutapost_savedo modelPost— na criação ou atualização de um post, chamabackup_post()post_tags_changed()→ escutam2m_changeddo modelPost.tags— sincroniza corretamente as tags relacionadas ao post
backup_post.py
A responsabilidade aqui é realizar o backup completo de um post.
A função backup_post() age como um orquestrador, decidindo o que salvar e onde salvar — as outras funções executam cada etapa.
Toda a implementação de cada passo vive nas funções individuais deste módulo.
Lista de funções do módulo:
get_image_pathscopy_imagesget_all_backed_up_imagesbuild_post_databuild_frontmattersave_markdown_filebackup_post
def backup_post(post, created=False, content_md=None):
backup_root = settings.BACKUP_ROOT
post_dir = os.path.join(backup_root, post.slug)
os.makedirs(post_dir, exist_ok=True)
if not content_md and post.editor_type == 'markdown':
content_md = post.content_markdown
image_paths = get_image_paths(post.content)
if created:
image_filenames = copy_images(image_paths, post_dir)
data = build_post_data(post, image_filenames, content_md)
json_path = os.path.join(post_dir, 'post.json')
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
if content_md:
save_markdown_file(post_dir, post, content_md)
else:
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
update_dir = os.path.join(post_dir, 'updates', f'update_{timestamp}')
os.makedirs(update_dir, exist_ok=True)
already_backed_up = get_all_backed_up_images(post_dir)
new_image_paths = [
p for p in image_paths
if os.path.basename(p) not in already_backed_up
]
image_filenames = copy_images(new_image_paths, update_dir) if new_image_paths else []
data = build_post_data(post, image_filenames, content_md)
json_path = os.path.join(update_dir, 'post.json')
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
if content_md:
save_markdown_file(update_dir, post, content_md)
get_image_paths()→ o HTML do post é escaneado com regex para extrair o caminho das imagenscopy_images()→ copia todas as imagens relacionadas ao post doMEDIA_ROOTpara a pasta de backup do postget_all_backed_up_images()→ lê o post.json raiz e o de todos os updates anteriores para construir um conjunto completo de imagens já salvas, garantindo que não sejam salvas duas vezesbuild_post_data()→ monta todos os campos do post em formato de dicionario, que é o que se torna opost.jsonbuild_frontmatter()→ escreve o cabeçalho YAML usado no arquivo.mdsave_markdown_file()→ escreve o arquivopost.mdno discobackup_post()→ orquestrador do sistema de backups
restore_post.py
Os Django Management Commands permitem criar comandos customizados executados via manage.py.
No sistema de backups, eles são utilizados na pipeline de restore, permitindo reconstruir posts diretamente pelo terminal reutilizando toda a infraestrutura do Django.
Lista de funções do módulo:
get_latest_update-
collect_all_images -
Command (class)add_argumentshandle
def get_latest_update(backup_path):
updates_dir = os.path.join(backup_path, 'updates')
if not os.path.exists(updates_dir):
return None
updates = sorted(os.listdir(updates_dir))
return updates[-1] if updates else None
def collect_all_images(backup_path, target_update=None):
images = {}
root_images_dir = os.path.join(backup_path, 'images')
if os.path.exists(root_images_dir):
for filename in os.listdir(root_images_dir):
images[filename] = os.path.join(root_images_dir, filename)
updates_dir = os.path.join(backup_path, 'updates')
if os.path.exists(updates_dir):
for update_folder in sorted(os.listdir(updates_dir)):
update_images_dir = os.path.join(updates_dir, update_folder, 'images')
if os.path.exists(update_images_dir):
for filename in os.listdir(update_images_dir):
images[filename] = os.path.join(update_images_dir, filename)
if target_update and update_folder == target_update:
break
return images
...
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('backup_path', type=str)
parser.add_argument('--update', type=str)
parser.add_argument('--dry-run', action='store_true')
def handle(self, *args, **options):
if not update:
update = get_latest_update(backup_path)
if update:
json_path = os.path.join(backup_path, 'updates', update, 'post.json')
else:
json_path = os.path.join(backup_path, 'post.json')
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
all_images = collect_all_images(backup_path, target_update=update)
self.stdout.write(f'Restoring: {data["title"]}')
...
if dry_run:
return
category, _ = Category.objects.get_or_create(name=data['category'])
tag, _ = Tag.objects.get_or_create(name=tag_name)
post, created = Post.objects.update_or_create(slug=data['slug'], defaults={...})
post.tags.set(tags)
for filename, src in all_images.items():
shutil.copy2(src, dst)
get_latest_update()→ escaneia a pasta de updates de um post e retorna o mais recente (usado quando um restore não especifica nenhum update)-
collect_all_images()→ percorre o histórico completo de imagens — do backup raiz até o update especificado — e restaura todas -
Command (class)add_arguments()→ adiciona argumentos que podem ser usados na CLI deste comandohandle()→ orquestra toda a sequência de restore. Permite especificar comandos CLI como--updatee--dry-run. Reconstrói o post no banco de dados e as imagens no disco
Conclusão
O blog pode ser reconstruído e as configurações podem ser refeitas. O que não pode ser recuperado é o que eu escrevi.
O sistema de backups existe para proteger exatamente isso.
Uma VPS de 20GB me forçou a construir algo eficiente por necessidade. O resultado foi um sistema que armazena apenas o que mudou, somente quando mudou — que, coincidentemente, acabou sendo também a abordagem mais inteligente, independente da restrição.
Além disso, eu queria backups legíveis, versionáveis e de fácil restauração em qualquer ambiente. O meu sistema entrega exatamente isso.