summaryrefslogtreecommitdiffstats
path: root/roles/backup/README.md
blob: 88bdf7d4a1c070882769b9d4334c77796f8dedeb (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# Ansible Role: backup

This role backs up a set of paths to an S3-compatible restic repository, on a schedule,
and can restore a snapshot back on demand.

## Role Variables

### Identity

- `backup_name` **Required for multiple jobs** unique name for this backup job — used
  for the systemd unit/cron entry, script name, and as a sub-path in the restic
  repository so multiple jobs can share one bucket (default: `"backup"`).
- `backup_user` / `backup_group` unprivileged system account the scheduled backup job
  runs as (default: `"backup"` / `"backup"`). Restore does not use this account — see
  Restore Process below.

### What to back up

- `backup_paths` **Required** list of paths to back up.

### Schedule

- `backup_systemd_schedule` systemd `OnCalendar` schedule (e.g. `daily`, `weekly`,
  `*-*-* 02:00`) — used on systemd hosts (default: `"daily"`).
- `backup_cron_schedule` cron schedule (e.g. `0 0 * * *`) — used on OpenRC hosts,
  since there's no systemd timer there (default: `"0 0 * * *"`).

### Retention

- `backup_retention_daily` number of daily snapshots to keep (default: `7`).
- `backup_retention_weekly` number of weekly snapshots to keep (default: `4`).
- `backup_retention_monthly` number of monthly snapshots to keep (default: `6`).

### Restic repository

- `backup_repo` **Required** restic repository URL (e.g.
  `s3:http://machine.local:3900/backups`). The role appends `/{{ backup_name }}` to
  this automatically, so multiple jobs can share one bucket.
- `backup_access_key_id` **Required** S3 access key ID.
- `backup_secret_access_key` **Required** S3 secret access key.
- `backup_password` **Required** restic repository encryption password. Losing this
  makes existing snapshots unrecoverable — it's not stored anywhere except the target
  host's credential file.

### Restore (only used with `--tags restore`, see below)

- `restore_target` **Required for restore** directory to restore snapshot contents
  into.
- `backup_restore_id` snapshot ID to restore, or `"latest"` (default: `"latest"`).

## Backup Process

- Creates the `backup_user`/`backup_group` system account (`nologin` shell, no home
  directory) — restic never runs as root.
- Deploys credentials to `/etc/credstore/{{ backup_name }}-backup.env`, root:root,
  mode `0600`. Values are quoted so `source`-ing the file can't be broken or exploited
  by special characters in a password. Variable names are `BACKUP_*`, not `RESTIC_*`/
  `AWS_*` — those are ambient names every S3/restic-aware tool auto-picks up from the
  environment, so keeping the on-disk file under inert names avoids a stray `source`
  elsewhere on the host silently redirecting some other tool's credentials.
- Creates a dedicated restic cache directory (`/var/cache/{{ backup_name }}-backup`),
  owned by `backup_user`, since that account has no home directory for restic to use
  by default.
- Schedules the actual backup job, split by init system:
  - **systemd**: a `LoadCredential=`-based service + timer. `LoadCredential` copies
    the credential file into a private, tmpfs-backed, per-run directory — the secret
    never lands in the unit's own tracked environment (unlike `EnvironmentFile=`,
    which would make it visible via `systemctl show`).
  - **OpenRC**: a `blockinfile`-managed entry in `/etc/crontabs/root` (append-only —
    won't clobber Alpine's default periodic maintenance jobs already in that file).
    Since there's no systemd credential broker, the script itself runs as root,
    reads the credential file directly, then drops to `backup_user` via `su -p`
    before ever touching restic or the backup paths.
- The backup script itself: `restic snapshots` (init the repo if this is the first
  run), `restic backup <paths>`, then `restic forget --prune` using the configured
  retention.

## Restore Process

Restore never runs automatically — every restore-related task is tagged
`["never", "restore"]`, Ansible's idiom for "only run when explicitly asked." A normal
`ansible-playbook` run touches none of it, not even deploying the restore script.

Restore always runs `restic restore --delete`, so `restore_target` ends up matching
the restored snapshot exactly — anything under the backed-up paths that was added
since the snapshot was taken gets removed, not just overlaid. `--delete` only prunes
within the backed-up paths themselves (e.g. `/srv/git`), not the rest of
`restore_target` even when that's `/`.

To restore, run the play with `--tags restore` and supply `restore_target`:

```bash
ansible-playbook service.yml --tags restore -e restore_target=/var/restore/git
```

To restore a specific snapshot instead of the latest one, also pass
`backup_restore_id`. To find available snapshot IDs, on the target host:

```bash
source /etc/credstore/{{ backup_name }}-backup.env
export RESTIC_REPOSITORY="$BACKUP_REPO" RESTIC_PASSWORD="$BACKUP_PASSWORD" \
       AWS_ACCESS_KEY_ID="$BACKUP_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$BACKUP_SECRET_ACCESS_KEY"
restic --cache-dir "/var/cache/{{ backup_name }}-backup" snapshots
```

Then:

```bash
ansible-playbook service.yml --tags restore \
  -e restore_target=/var/restore/git -e backup_restore_id=<snapshot-id>
```

### Caveats

- Unlike backup, restore runs as root rather than dropping to `backup_user`. It needs
  to write into paths owned by other service accounts and restore their original
  ownership/permissions (restic records each file's original UID/GID and restores it
  by default, which requires root). This is a deliberately different privilege model
  from the scheduled, unattended backup job — restore only ever runs when explicitly
  invoked via `--tags restore`, a human decision each time, not something that runs
  on its own.
- `restore_target` is always required explicitly — there's no default. This is
  deliberate: restore is destructive if pointed at the wrong place, so the target
  must be a conscious choice every time, not something that silently falls back to
  a default path.