Ferramentas

Borg como solução de backup.

Launchd para criação de backups periódicos

Passos

  • Criar um repositório do Borg: Será armazenado nesse diretório todos os backups feitos e seus snapshots incrementais.
    • borg init -e=repokey ~/home-backup
  • (opcional) Exclusão / Inclusão de arquivos no backup: Pode-se criar um arquivo para incluir ou excluir arquivos ou diretórios do backup.
  • (opcional) Remover arquivos/diretórios que já estão nos backups existentes: editar o arquivo de patterns só vale para os próximos backups — para retroagir e liberar espaço use recreate.
  • Comando para criar os backups:
    • borg create --progress --stats --patterns-from ~/borg-backup-home.lst ~/home-backup::'{now}'
  • Listando os backups:
    • borg list ~/home-backup
  • Compactando os backups: Rode o seguinte comando para compactar os backups eliminando segmentos não utilizados.
    • borg compact /Users/my-user/home-backup
  • Prune os backups: Para limpeza dos backups antigos ou repetidos, pode-se usar o comando prune:
    • borg prune --keep-within=2d --keep-daily=7 --keep-weekly=8 --keep-monthly=12 --keep-yearly=3 ~/home-backup
    • A explicação desse comando pode ser vista em Explicação do comando prune
  • (opcional) Sync do backup para um bucket: Para enviar para a nuvem, pode usar um serviço do tipo “Google Drive” / “Dropbox” ou subir em um bucket. Exemplo:
    • gsutil rsync -d ~/home-backup gs://my-bucket
  • Automatizar backup e sync: No OsX (Mac), pode-se usar o Launchd para realizar o backup periodicamente. Para isso

Outros

Shell para backup

Pode-se guarda-lo em ~/borg-home-backup.sh

Versão mínima (ok para rodar na mão):

#!/usr/bin/env bash
 
export BORG_PASSPHRASE="my-passprase"
 
/opt/homebrew/bin/borg create --progress --stats --patterns-from /Users/my-user/borg-backup-home.lst /Users/my-user/home-backup::'{now}'
/opt/homebrew/bin/borg prune --keep-daily=5 --keep-weekly=14 --keep-monthly=4 --keep-yearly=12 /Users/my-user/home-backup
/opt/homebrew/bin/borg compact /Users/my-user/home-backup
 
echo "Syncing to GCP Bucket"
 
/Users/my-user/google-cloud-sdk/bin/gsutil rsync -d ~/home-backup gs://my-backup-bucket
 
# OR
aws s3 sync /Users/my-user/home-backup/ s3://my-bucket/ --delete

Versão para rodar sob Launchd

Para execução automática e frequente (a cada 15min), a versão mínima acima tem problemas: sem tratamento de erro, --progress polui o log fora do terminal, e prune + compact a cada execução são caros. Ver Armadilhas ao rodar backup sob launchd.

#!/usr/bin/env bash
#
# Backup do home com borg. Projetado para rodar via launchd
# (~/Library/LaunchAgents/com.my-user.borg.plist) e também na mão.
 
set -uo pipefail
 
BORG=/opt/homebrew/bin/borg
PASS=/opt/homebrew/bin/pass
 
REPO_DIR=/Users/my-user/home-backup
PATTERNS=/Users/my-user/borg-backup-home.lst
LOCK_DIR=/tmp/com.my-user.borg.lock
PRUNE_STAMP=/Users/my-user/.last-prune
 
# Evita rodar duas instâncias ao mesmo tempo (ex: launchd + execução manual).
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    echo "[$(date '+%F %T')] já existe backup em andamento ($LOCK_DIR) — saindo"
    exit 0
fi
trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT
 
log() { echo "[$(date '+%F %T')] $*"; }
 
# Passphrase vem do pass (ver "Automação sem expor a passphrase em texto plano").
if ! BORG_PASSPHRASE="$("$PASS" my-store/borg-passphrase)" || [[ -z "$BORG_PASSPHRASE" ]]; then
    log "ERRO: não conseguiu ler a passphrase do pass — abortando"
    exit 1
fi
export BORG_PASSPHRASE
 
# Sem isso o borg pergunta interativamente (e travaria no launchd).
export BORG_RELOCATED_REPO_ACCESS_IS_OK=no
export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=no
 
status=0
 
# borg: rc=0 sucesso, rc=1 warning (ex: arquivo sem permissão de leitura),
# rc>=2 erro real. Só rc>=2 deve marcar a execução como falha.
run_borg() {
    local what="$1"; shift
    local rc=0
    log "borg $what — início"
    "$BORG" "$@" || rc=$?
    case $rc in
        0) log "borg $what — ok" ;;
        1) log "borg $what — ok com warnings (rc=1)" ;;
        *) log "borg $what — FALHOU (rc=$rc)"; status=$rc ;;
    esac
    return $rc
}
 
run_borg create create --stats --lock-wait 600 \
    --patterns-from "$PATTERNS" "${REPO_DIR}::{now}"
 
# prune + compact são pesados; uma vez por dia é suficiente para a
# cadência de 15min. --keep-within=2d preserva os pontos intra-dia
# recentes (sem ele o prune apagaria os backups do mesmo dia).
if [[ $status -eq 0 && "$(cat "$PRUNE_STAMP" 2>/dev/null)" != "$(date +%F)" ]]; then
    run_borg prune prune --lock-wait 600 --stats \
        --keep-within=2d --keep-daily=7 --keep-weekly=10 \
        --keep-monthly=3 --keep-yearly=1 "$REPO_DIR"
    if [[ $status -eq 0 ]]; then
        run_borg compact compact --lock-wait 600 "$REPO_DIR"
        date +%F > "$PRUNE_STAMP"
    fi
fi
 
log "fim (rc=$status)"
exit $status

Pontos de projeto:

  • Lock por diretório (mkdir é atômico): o launchd já não roda duas instâncias do mesmo label, mas isso protege contra execução manual concorrente. Junto com --lock-wait 600, evita falha por lock do repositório.
  • rc=1 não é falha: o borg usa rc=1 para warnings. Tratar tudo != 0 como erro faz o job parecer quebrado quando só houve um arquivo ilegível.
  • prune/compact 1x/dia via arquivo de stamp: compact reescreve segmentos do repositório e é caro para rodar dezenas de vezes por dia.
  • Sem --progress: fora de um TTY só gera ruído no log.

Arquivo launchd para macosx

Crie o arquivo abaixo que periodicamente o shell ~/borg-home-backup.sh

Este serviço é programado para iniciar em quatro momentos diferentes no decorrer do dia, especificamente às 10:00, 13:00, 18:00 e 20:00.

Além disso, qualquer saída padrão ou erro gerado pelo serviço será redirecionado para dois arquivos diferentes localizados no diretório “/tmp”, nomeados como “com.my-user.borg.out” e “com.my-user.borg.err”, respectivamente.

Pode-se guarda-lo em ~/Library/LaunchAgents/com.my-user.borg.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>Label</key>
        <string>com.my-user.borg</string>
 
	    <key>EnvironmentVariables</key>
	    <dict>
	      <key>PATH</key>
	      <string>/opt/homebrew/opt/gnu-getopt/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin</string>
	      <key>HOME</key>
	      <string>/Users/my-user</string>
	      <key>GNUPGHOME</key>
	      <string>/Users/my-user/.gnupg</string>
	      <key>LANG</key>
	      <string>en_US.UTF-8</string>
        </dict>
 
        <key>ProgramArguments</key>
        <array>
            <string>/Users/my-user/borg-home-backup.sh</string>
        </array>
 
        <key>StartCalendarInterval</key>
        <array>
            <dict>
                <key>Hour</key>
                <integer>10</integer>
                <key>Minute</key>
                <integer>0</integer>
            </dict>
            <dict>
                <key>Hour</key>
                <integer>13</integer>
                <key>Minute</key>
                <integer>0</integer>
            </dict>
            <dict>
                <key>Hour</key>
                <integer>18</integer>
                <key>Minute</key>
                <integer>0</integer>
            </dict>
            <dict>
                <key>Hour</key>
                <integer>20</integer>
                <key>Minute</key>
                <integer>0</integer>
            </dict>
        </array>
 
        <key>StandardOutPath</key>
        <string>/tmp/com.my-user.borg.out</string>
        <key>StandardErrorPath</key>
        <string>/tmp/com.my-user.borg.err</string>
    </dict>
</plist>

Cadência alta (ex: 15min no horário comercial, 1h à noite)

StartCalendarInterval não aceita intervalo — só uma lista de horários fixos (um dict por horário; a chave omitida é curinga). Para “a cada 15min das 10h às 18h, depois de hora em hora até a meia-noite” são 39 dicts. Vale gerar o plist em vez de escrever à mão:

entries = []
for h in range(10, 18):                      # 10:00 -> 17:45, a cada 15min
    for m in (0, 15, 30, 45):
        entries.append((h, m))
entries.append((18, 0))
for h in list(range(19, 24)) + [0]:          # de hora em hora até 00:00
    entries.append((h, 0))
 
cal = "\n".join(
    f"            <dict>\n"
    f"                <key>Hour</key>\n"
    f"                <integer>{h}</integer>\n"
    f"                <key>Minute</key>\n"
    f"                <integer>{m}</integer>\n"
    f"            </dict>"
    for h, m in entries
)

Detalhes que importam nessa cadência:

  • Um label = uma instância. O launchd não dispara um job que já está rodando; se o backup passar dos 15min, o horário seguinte é simplesmente perdido (não enfileira). Por isso vale manter tudo num único plist em vez de dois (um de 15min + um horário), que seriam dois labels e poderiam se sobrepor.
  • Máquina dormindo: horários perdidos durante o sono rodam uma vez ao acordar.
  • Prioridade: Nice + LowPriorityIO para o backup não competir com o trabalho interativo.
  • RunAtLoad false para o bootstrap não disparar um backup na hora.
        <key>Nice</key>
        <integer>5</integer>
        <key>LowPriorityIO</key>
        <true/>
        <key>RunAtLoad</key>
        <false/>

Validar o XML antes de carregar: plutil -lint ~/Library/LaunchAgents/com.my-user.borg.plist

Carregar / ativar o serviço

LaunchAgents são serviços de nível de usuário — não usar sudo (caso contrário o launchctl espera um caminho de LaunchDaemons e falha).

# Carregar (sintaxe legada, ainda funciona)
launchctl load ~/Library/LaunchAgents/com.my-user.borg.plist
 
# Carregar (sintaxe moderna recomendada)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.my-user.borg.plist

Descarregar / desativar o serviço

# Sintaxe legada
launchctl unload ~/Library/LaunchAgents/com.my-user.borg.plist
 
# Sintaxe moderna
launchctl bootout gui/$(id -u)/com.my-user.borg

Recarregar (descarregar e carregar novamente)

launchctl bootout gui/$(id -u)/com.my-user.borg
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.my-user.borg.plist

Verificar status

# Listar serviço e ver se está rodando
launchctl list | grep com.my-user.borg
 
# Detalhes do serviço (erros, última execução, etc.)
launchctl print gui/$(id -u)/com.my-user.borg

⚠️ Erro comum: usar sudo launchctl load com LaunchAgents causa Input/output error. Sempre use sem sudo para agentes em ~/Library/LaunchAgents/.

Testar o job sem esperar o horário

kickstart -p dispara o job no ambiente real do launchd (PATH, HOME, sem TTY, sem Full Disk Access). Testar rodando o shell no terminal não vale — o terminal tem permissões e variáveis que o launchd não tem.

: > /tmp/com.my-user.borg.out; : > /tmp/com.my-user.borg.err
launchctl kickstart -p gui/$(id -u)/com.my-user.borg
 
# depois de terminar:
launchctl print gui/$(id -u)/com.my-user.borg | grep -E 'last exit|runs ='
cat /tmp/com.my-user.borg.out
cat /tmp/com.my-user.borg.err

Armadilhas ao rodar backup sob launchd

Três problemas que só aparecem no ambiente do launchd (nenhum acontece rodando o script no terminal):

1. TCC / Full Disk Access → rc=1 em toda execução

O processo do launchd não tem Full Disk Access, então não consegue entrar em diretórios protegidos pelo TCC: ~/Library/Mail, ~/Library/Containers, ~/Library/Group Containers, ~/Library/Application Support/AddressBook, ~/Pictures/*.photoslibrary, ~/.Trash, etc. O log enche de:

/Users/my-user/Library/Mail: dir_open: [Errno 1] Operation not permitted: 'Mail'

e o borg termina com rc=1. Duas saídas:

  • Se você não quer esses diretórios no backup (caso comum): use ! em vez de - no arquivo de patterns, para o borg nem entrar neles. Ver Arquivo para exclusão / inclusão. Além de calar os erros, corta muito I/O — num home de ~108k arquivos o create caiu de 1m28s para 31s.
  • Se você quer (~/Documents, ~/Library…): conceder Full Disk Access ao binário do borg (/opt/homebrew/bin/borg) em Ajustes → Privacidade e Segurança.

2. prune a cada execução apaga os backups intra-dia

borg prune --keep-daily=N mantém apenas o último arquivo de cada dia. Rodando prune a cada 15min, todos os pontos anteriores do dia corrente são apagados na hora — o que anula completamente o sentido de fazer backup a cada 15min.

Solução: --keep-within=2d (mantém tudo das últimas 48h) e rodar prune/compact só uma vez por dia.

3. pass / GPG sem TTY

pass depende do gpg-agent, que pode precisar de pinentry — e no launchd não há TTY nem sessão gráfica para exibi-lo. Verificar antes, simulando o ambiente:

env -i HOME=$HOME PATH=/opt/homebrew/bin:/usr/bin:/bin pass my-store/borg-passphrase

Se funcionar, a chave está sem passphrase no disco (gpg-connect-agent 'keyinfo --list' /bye mostra C = clear no campo de proteção). Se pedir passphrase, é preciso instalar pinentry-mac e deixar o agente com cache longo, ou usar BORG_PASSPHRASE_FILE com permissão restrita.

Arquivo para exclusão / inclusão

Pode-se guarda-lo em ~/borg-backup-home.lst

Prefixos (avaliados na ordem do arquivo — o primeiro match ganha):

PrefixoSignificado
RRoot — o caminho raiz a partir do qual o borg recursa
+Inclui
-Exclui, mas ainda entra no diretório para testar os filhos
!Exclui e não entra no diretório (no-recurse)

A diferença entre - e ! é a armadilha principal: com - ~/Library o borg ainda percorre toda a árvore do Library só para descobrir que cada item está excluído — gastando I/O e, sob launchd, batendo em erros de permissão do TCC (ver Armadilhas ao rodar backup sob launchd).

Cuidado com a ordem: um ! num diretório pai impede que um + posterior alcance um filho dele. Se você precisa de ~/Library/LaunchAgents, o + dele tem que vir antes do ! ~/Library/** — e o padrão precisa ser ! /Users/my-user/Library/** (com /**), não ! /Users/my-user/Library, senão o borg nem entra no Library e nunca chega no LaunchAgents.

R /Users/my-user/

# Any inside dir...
- **/cache
- **/cache-chat
- **/.git
- **/node_modules

# Emacs temp
- **/*~
- **/\#*

# borg backup script — só precisa excluir na versão que tem a passphrase
# inline; usando `pass` o script pode (e deve) ir para o backup.
- /Users/my-user/borg-home-backup.sh

# Dirs from home
- /Users/my-user/.docker
- /Users/my-user/.pyenv
- /Users/my-user/.nuget
- /Users/my-user/.nvm
- /Users/my-user/.npm
- /Users/my-user/.vscode
- /Users/my-user/.local

- /Users/my-user/Applications
- /Users/my-user/javasharedresources
- /Users/my-user/nltk_data
- /Users/my-user/google-cloud-sdk
- /Users/my-user/go
- /Users/my-user/confluent
- /Users/my-user/home-backup # backup itself

# Library: quero só o LaunchAgents — o "+" vem antes do "!" do pai.
# "!" (e não "-") para não percorrer a árvore inteira nem bater no TCC.
+ /Users/my-user/Library/LaunchAgents
! /Users/my-user/Library/**
! /Users/my-user/.Trash
! /Users/my-user/Pictures

# Personal workspace temp dir (trash things...)
- /Users/my-user/wo/personal/temp

# dirs with data from kafka (messages in files)
- **/kafka-consumer/data-*

Como remover arquivos e diretórios já existentes nos backups (recreate)

Editar o arquivo de patterns (ver Arquivo para exclusão / inclusão) e deixar o create rodar só afeta os próximos backups — o que já foi gravado em snapshots antigos continua ocupando espaço no repositório, porque prune só apaga snapshots inteiros, não arquivos de dentro deles. Para remover retroativamente de todos os snapshots existentes (e recuperar o espaço em disco), é preciso reescrever o histórico com recreate:

  1. Editar o arquivo de patterns, adicionando a(s) regra(s) de exclusão (prefixo - ou !) para o que não deve mais entrar no backup. Ex: parar de guardar .git/node_modules dentro de um diretório específico:
    - /Users/my-user/algum-dir/**/.git
    - /Users/my-user/algum-dir/**/node_modules
    
  2. Rodar recreate apontando para o mesmo arquivo de patterns editado — ele reaplica os filtros em todos os snapshots já existentes no repositório, não só nos novos:
    BORG_PASSPHRASE="..." borg recreate --progress --stats --lock-wait 600 \
        --patterns-from ~/borg-backup-home.lst ~/home-backup
  3. Compactar para liberar o espaço em disco de fato (o recreate sozinho reescreve os archives mas não encolhe o repositório):
    borg compact ~/home-backup
  4. (opcional) Checar integridade depois de uma operação grande:
    borg check --repository-only ~/home-backup

Pontos de atenção:

  • recreate não lê o arquivo de patterns sozinho — é preciso passar --patterns-from (ou -e/--exclude-from/--pattern) explicitamente, mesmo reaproveitando o mesmo arquivo usado pelo create. Sem isso, ele reescreve os archives sem excluir nada.
  • A linha R /Users/my-user/ do arquivo de patterns define a raiz que o create usa para recursar o filesystem; no recreate ela não tem esse papel (não há filesystem envolvido, só o conteúdo já arquivado) — mas as regras +/-/! continuam funcionando normalmente como filtro sobre os caminhos já salvos.
  • É uma operação lenta e pesada: reescreve todos os archives do repositório — num repo com dezenas de snapshots e vários GB, pode levar minutos.
  • É irreversível: os IDs dos archives mudam e o conteúdo removido não pode ser recuperado depois — diferente do prune, que só descarta snapshots inteiros.
  • Evitar rodar junto com o backup automático: como o recreate segura o lock do repositório, um create agendado via Launchd pode falhar por timeout de lock (--lock-wait) se disparar no meio. Vale descarregar o agent antes (launchctl bootout gui/$(id -u)/com.my-user.borg) e recarregar depois (launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.my-user.borg.plist) — ver Carregar / ativar o serviço e Descarregar / desativar o serviço.

Explicação do comando prune

Exemplo: borg prune --keep-within=2d --keep-daily=7 --keep-weekly=8 --keep-monthly=12 --keep-yearly=3 ~/home-backup

Explicação:

Este comando é usado para podar (ou seja, excluir) backups antigos.

Aqui está o que cada opção faz:

--keep-within=2d: Mantém todos os backups feitos dentro dos últimos 2 dias (48h). --keep-daily=7: Mantém um backup diário pelos últimos 7 dias. --keep-weekly=8: Mantém um backup semanal pelas últimas 8 semanas. --keep-monthly=12: Mantém um backup mensal pelos últimos 12 meses. --keep-yearly=3: Mantém um backup anual pelos últimos 3 anos.

O último argumento ~/home-backup é o diretório que contém os backups.

Portanto, este comando irá excluir todos os backups que não se enquadrem nessas regras.

Gerenciamento de Chaves e Criptografia

O Borg usa criptografia AES-256 em duas camadas:

Passphrase (você memoriza/guarda)
    └─► decripta ──► Repository Key (AES-256)
                         └─► decripta ──► dados dos backups

Modos de criptografia

ModoOnde a chave ficaQuando usar
repokey-blake2No próprio repositórioRecomendado — maioria dos casos
keyfile-blake2Só na máquina cliente (~/.config/borg/keys/)Servidor não confiável (ex: cloud)
none—Repositórios locais sem necessidade de sigilo

Diferença crucial: no modo repokey, a chave cifrada fica embutida no repositório. No modo keyfile, a chave não está no repositório — sem ela e a passphrase, os dados são inacessíveis mesmo com acesso total ao servidor.

Exportar a chave (faça isso logo após criar o repo)

# Exportar como arquivo
borg key export /path/to/repo ~/borg.key
 
# Exportar em formato legível por humanos (para imprimir/papel)
borg key export --paper /path/to/repo

⚠️ Com keyfile, se perder a máquina sem esse export, perde os dados para sempre.

Proteger a chave exportada com GPG

gpg --symmetric --cipher-algo AES256 ~/borg.key
shred -u ~/borg.key  # apagar versão em texto plano
# guarde o borg.key.gpg em local separado do repositório

Trocar passphrase

borg key change-passphrase /path/to/repo

Automação sem expor a passphrase em texto plano

Evite BORG_PASSPHRASE="texto" direto no script. Prefira:

# Via arquivo com permissão restrita
export BORG_PASSPHRASE_FILE=/root/.borg-passphrase  # chmod 400
 
# Via GPG (requer gpg-agent rodando)
export BORG_PASSPHRASE=$(gpg --quiet --batch --decrypt ~/.safe/borg-pass.gpg)

Verificar integridade do repositório

borg check /path/to/repo              # verificação rápida (metadados)
borg check --verify-data /path/to/repo  # completa (mais lento)

Checklist de disaster recovery

Guarde em local seguro (gerenciador de senhas, cofre):

  • Passphrase do repositório
  • Export da chave (borg key export)
  • Endereço/caminho do repositório
  • Versão do Borg usada
  • Comando para restaurar: borg mount /repo /mnt/borg