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_image
  • post_saved
  • post_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() → escuta post_save do model Attachment (Summernote) — quando uma imagem é salva, chama um módulo auxiliar de redimensionamento de imagens (que utiliza Pillow)
  • post_saved() → escuta post_save do model Post — na criação ou atualização de um post, chama backup_post()
  • post_tags_changed() → escuta m2m_changed do model Post.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_paths
  • copy_images
  • get_all_backed_up_images
  • build_post_data
  • build_frontmatter
  • save_markdown_file
  • backup_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 imagens
  • copy_images() → copia todas as imagens relacionadas ao post do MEDIA_ROOT para a pasta de backup do post
  • get_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 vezes
  • build_post_data() → monta todos os campos do post em formato de dicionario, que é o que se torna o post.json
  • build_frontmatter() → escreve o cabeçalho YAML usado no arquivo .md
  • save_markdown_file() → escreve o arquivo post.md no disco
  • backup_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_arguments
    • handle
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 comando
    • handle() → orquestra toda a sequência de restore. Permite especificar comandos CLI como --update e --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.