BTRFS snapshots in Fedora: the definitive, step-by-step, easy and safe guide

Important premises

This guide is designed (and works) only for those who install Fedora in the classic way, i.e. with the default partitioning and subvolume layouts:

lsblk

NAME                                          MAJ:MIN RM   SIZE RO TYPE  MOUNTPOINTS
zram0                                         xyz:a    0     XG  0 disk  [SWAP]
nvme0n1                                       xyz:a    0 XXX,XG  0 disk  
├─nvme0n1p1                                   xyz:a    0   XXXM  0 part  /boot/efi
├─nvme0n1p2                                   xyz:a    0     XG  0 part  /boot
└─nvme0n1p3                                   xyz:a    0 XXX,XG  0 part  
  └─luks-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx xyz:a    0 XXX,XG  0 crypt /home
                                                                         /

sudo btrfs subvolume list /

ID xxx gen yyyyyy top level 5 path home
ID xxx gen yyyyyy top level 5 path root

In this guide we will explore how to take “system” snapshots, therefore only of the / subvolume.

The /boot partition (because in ext4) and the /home subvolume are not included in the snapshot (snapshotting the latter is useless and would only make the snapshots heavier; I still recommend using a backup system for your data in /home, for example through applications like Déjà Dup or Pika Backup).
More details on the filesystem types used by partitions with:

lsblk -f

Since it is not possible to snapshot /boot, which contains the installed kernels, we will configure Snapper (the tool we are going to use to snapshot) not to retain snapshots that are too old: in this way we avoid problems booting from a snapshot for which the kernel it needs is no longer present.

While there are two great graphics tools (GUI) for this type of work, they won’t be used in this guide. This is because neither of them fully meets the requirements that an app of this type should have: being simple to configure and being perfectly integrated with the system.
Neither Timeshift nor Btrfs Assistant meets all of these requirements: Timeshift does not work with Fedora’s default subvolume layout; Btrfs Assistant does not integrate at all with the GNOME environment (which is Fedora’s default DE) and is not particularly intuitive for a novice user.


Packages to install before starting

Install this COPR repo:

sudo dnf copr enable zzahkaboom24/grub-btrfs

and then install these packages:

sudo dnf install snapper grub-btrfs libdnf5-plugin-actions python3-btrfsutil

Let’s get started!

First of all let’s configure Snapper:

sudo snapper -c root create-config /
sudo nano /etc/snapper/configs/root

# subvolume to snapshot
SUBVOLUME="/"

# filesystem type
FSTYPE="btrfs"


# btrfs qgroup for space aware cleanup algorithms
QGROUP=""


# fraction or absolute size of the filesystems space the snapshots may use
SPACE_LIMIT="0.5"

# fraction or absolute size of the filesystems space that should be free
FREE_LIMIT="0.2"


# users and groups allowed to work with config
ALLOW_USERS=""
ALLOW_GROUPS=""

# sync users and groups from ALLOW_USERS and ALLOW_GROUPS to .snapshots
# directory
SYNC_ACL="no"


# start comparing pre- and post-snapshot in background after creating
# post-snapshot
BACKGROUND_COMPARISON="yes"


# run daily number cleanup
NUMBER_CLEANUP="yes"

# limit for number cleanup
NUMBER_MIN_AGE="3600"
NUMBER_LIMIT="6"
NUMBER_LIMIT_IMPORTANT="4"


# create hourly snapshots
TIMELINE_CREATE="yes"

# cleanup hourly snapshots after some time
TIMELINE_CLEANUP="yes"

# limits for timeline cleanup
TIMELINE_MIN_AGE="3600"
TIMELINE_LIMIT_HOURLY="5"
TIMELINE_LIMIT_DAILY="3"
TIMELINE_LIMIT_WEEKLY="0"
TIMELINE_LIMIT_MONTHLY="0"
TIMELINE_LIMIT_QUARTERLY="0"
TIMELINE_LIMIT_YEARLY="0"


# cleanup empty pre-post-pairs
EMPTY_PRE_POST_CLEANUP="yes"

# limits for empty pre-post-pair cleanup
EMPTY_PRE_POST_MIN_AGE="3600"
sudo nano /etc/dnf/libdnf5-plugins/actions.d/snapper.actions

# Get the snapshot description
pre_transaction::::/usr/bin/sh -c echo\ "tmp.cmd=$(ps\ -o\ command\ --no-headers\ -p\ '${pid}')"

# Creates pre snapshots for root and stores snapshot numbers in variables
pre_transaction::::/usr/bin/sh -c echo\ "tmp.snapper_pre_root=$(snapper\ -c\ root\ create\ -c\ number\ -t\ pre\ -p\ -d\ '${tmp.cmd}')"

# Creates post snapshots for root if pre snapshot numbers exist
post_transaction::::/usr/bin/sh -c [\ -n\ "${tmp.snapper_pre_root}"\ ]\ &&\ snapper\ -c\ root\ create\ -c\ number\ -t\ post\ --pre-number\ "${tmp.snapper_pre_root}"\ -d\ "${tmp.cmd}"

Now let’s verify that the daemons used by Snapper are working properly:

systemctl status snapper-timeline.timer
systemctl status snapper-cleanup.timer

Both must be enabled and active, otherwise:

sudo systemctl enable --now snapper-timeline.timer
sudo systemctl enable --now snapper-cleanup.timer

Ok, so far we have already done most of the work. Snapshots will be automatically created every hour and every pre- and post-installation or package update, and older ones will be automatically deleted. Note that mine is just an example configuration: if you are an experienced user, you can configure Snapper as you see fit.

To view the updated list of available snapshots at any time, simply launch:

sudo snapper list

Now let’s continue by configuring grub-btrfs. This package allows you to get a list of available snapshots directly in the GRUB boot menu, in case the system fails to boot and you need to boot from a snapshot.

sudo systemctl edit --full grub-btrfsd

[Unit]
Description=Regenerate grub-btrfs.cfg

[Service]
Type=simple
LogLevelMax=notice
# Set the possible paths for `grub-mkconfig`
Environment="PATH=/sbin:/bin:/usr/sbin:/usr/bin"
# Load environment variables from the configuration
EnvironmentFile=/etc/default/grub-btrfs/config
# Start the daemon, usage of it is:
# grub-btrfsd [-h, --help] [-t, --timeshift-auto] [-l, --log-file LOG_FILE] SNAPSHOTS_DIRS
# SNAPSHOTS_DIRS         Snapshot directories to watch, without effect when --timeshift-auto
# Optional arguments:
# -t, --timeshift-auto  Automatically detect Timeshifts snapshot directory
# -o, --timeshift-old   Activate for timeshift versions <22.06
# -l, --log-file        Specify a logfile to write to
# -v, --verbose         Let the log of the daemon be more verbose
# -s, --syslog          Write to syslog
ExecStart=/usr/bin/grub-btrfsd --syslog /.snapshots

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now grub-btrfsd
sudo grub2-mkconfig -o /boot/grub2/grub.cfg

Reboot.

We conclude by installing this script which will greatly simplify the rollback process, automating the entire process and reducing it to a single command.

git clone 'https://github.com/jrabinow/snapper-rollback.git'
cd snapper-rollback
sudo cp snapper-rollback.py /usr/local/sbin/snapper-rollback
sudo cp snapper-rollback.conf /etc/
sudo nano /etc/snapper-rollback.conf

# Rollback to snapper snapshot
#
# Requires a flat subvolume layout like specified here:
# https://wiki.archlinux.org/index.php/Snapper#Suggested_filesystem_layout
#
# Run with snapshot number as an argument like "snapper-rollback 642"
# This can be run either from your installed system or from a live arch ISO if
# you adjust the settings accordingly
#
# Some terminology:
#  - your linux root is what gets mounted to `/` at boot time. Referred to
#    as `main` in this tool
#  - your btrfs root is the btrfs toplevel subvolume with subvol id = 5. See
#    https://btrfs.readthedocs.io/en/latest/btrfs-subvolume.html#description
#    for more info.
#    Referred to as `root` in this tool.

# config for btrfs root
[root]
# Name of your linux root subvolume
subvol_main = root
# Name of your snapper snapshot subvolume
subvol_snapshots = root/.snapshots
# Directory to which your btrfs root is mounted.
mountpoint = /btrfsroot
# Device file for btrfs root.
# If your btrfs root isn't mounted to `mountpoint` directory, then automatically
# mount this device there before rolling back. This parameter is optional, but
# if unset, you'll have to mount your btrfs root manually.
dev = /dev/*

BE CAREFUL: replace /dev/* with the device on which the root subvolume is installed (in my case it is /dev/mapper/luks-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, but yours may be different!

Just another step: due to this issue, you need to edit /usr/local/sbin/snapper-rollback

sudo nano /usr/local/sbin/snapper-rollback

#!/usr/bin/env -S python3
# -*- coding: utf-8 -*-

"""
Script to rollback to snapper snapshot using the layout proposed in the snapper
archwiki page
https://wiki.archlinux.org/index.php/Snapper#Suggested_filesystem_layout
"""

from datetime import datetime

import argparse
import btrfsutil
import configparser
import logging
import os
import pathlib
import sys


LOG = logging.getLogger()
LOG.setLevel("INFO")
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
ch = logging.StreamHandler()
ch.setFormatter(formatter)
LOG.addHandler(ch)


def parse_args():
    parser = argparse.ArgumentParser(
        description="Rollback to snapper snapshot based on snapshot ID",
    )
    parser.add_argument(
        "snap_id", metavar="SNAPID", type=str, help="ID of snapper snapshot"
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="don't actually do anything, just print the actions out",
    )
    parser.add_argument(
        "-c",
        "--config",
        type=str,
        default="/etc/snapper-rollback.conf",
        help="configuration file to use (default: /etc/snapper-rollback.conf)",
    )
    args = parser.parse_args()
    return args


def read_config(configfile):
    config = configparser.ConfigParser()
    config.read(configfile)
    return config


def ensure_dir(dirpath, dry_run=False):
    if not os.path.isdir(dirpath):
        try:
            if dry_run:
                LOG.info("mkdir -p '{}'".format(dirpath))
            else:
                os.makedirs(dirpath)
        except OSError as e:
            LOG.fatal("error creating dir '{}': {}".format(dirpath, e))
            raise


def mount_subvol_id5(target, source=None, dry_run=False):
    """
    There is no built-in `mount` function in python, let's shell out to an `os.system` call
    Also see https://stackoverflow.com/a/29156997 for a cleaner alternative
    """

    ensure_dir(target, dry_run=dry_run)

    if not os.path.ismount(target):
        shellcmd = "mount -o subvolid=5 {} {}".format(source or "", target)
        if dry_run:
            LOG.info(shellcmd)
            ret = 0
        else:
            ret = os.system(shellcmd)
        if ret != 0:
            raise OSError("unable to mount {}".format(target))


def fix_path_after_rename(path, subvol_main, subvol_main_newname):
    """
    If path is inside subvol_main, rewrite it to use subvol_main_newname instead,
    since subvol_main has been renamed to subvol_main_newname.
    """
    path_str = str(path)
    main_str = str(subvol_main) + '/'
    if path_str.startswith(main_str):
        return pathlib.Path(str(subvol_main_newname) + '/' + path_str[len(main_str):])
    return path


def rollback(subvol_main, subvol_main_newname, subvol_rollback_src, dev, dry_run=False):
    """
    Rename linux root subvolume, then create a snapshot of the subvolume to
    the old linux root location
    """
    try:
        if dry_run:
            LOG.info("mv {} {}".format(subvol_main, subvol_main_newname))
            actual_src = fix_path_after_rename(subvol_rollback_src, subvol_main, subvol_main_newname)
            LOG.info(
                "btrfs subvolume snapshot {} {}".format(
                    actual_src, subvol_main
                )
            )
            LOG.info("btrfs subvolume set-default {}".format(subvol_main))
        else:
            os.rename(str(subvol_main), str(subvol_main_newname))
            actual_src = fix_path_after_rename(subvol_rollback_src, subvol_main, subvol_main_newname)
            btrfsutil.create_snapshot(str(actual_src), str(subvol_main))
            btrfsutil.set_default_subvolume(str(subvol_main))
        LOG.info(
            "{}Rollback to {} complete. Reboot to finish".format(
                "[DRY-RUN MODE] " if dry_run else "", subvol_rollback_src
            )
        )
    except FileNotFoundError as e:
        LOG.fatal(
            f"Missing {subvol_main}: Is {dev} mounted with the option subvolid=5?"
        )
    except btrfsutil.BtrfsUtilError as e:
        LOG.error(f"{e}")
        if not os.path.isdir(str(subvol_main)):
            LOG.info(f"Moving {subvol_main_newname} back to {subvol_main}")
            if dry_run:
                LOG.warning("mv {} {}".format(subvol_main_newname, subvol_main))
            else:
                os.rename(str(subvol_main_newname), str(subvol_main))


def main():
    args = parse_args()
    config = read_config(args.config)

    mountpoint = pathlib.Path(config.get("root", "mountpoint"))
    subvol_main = mountpoint / config.get("root", "subvol_main")
    subvol_rollback_src = (
        mountpoint / config.get("root", "subvol_snapshots") / args.snap_id / "snapshot"
    )
    try:
        dev = config.get("root", "dev")
    except configparser.NoOptionError as e:
        dev = None

    confirm_typed_value = "CONFIRM"
    try:
        confirmation = input(
            f"Are you SURE you want to rollback? Type '{confirm_typed_value}' to continue: "
        )
        if confirmation != confirm_typed_value:
            LOG.fatal("Bad confirmation, exiting...")
            sys.exit(0)
    except KeyboardInterrupt as e:
        sys.exit(1)

    date = datetime.now().strftime("%Y-%m-%dT%H:%M")
    subvol_main_newname = pathlib.Path(f"{subvol_main}{date}")
    try:
        mount_subvol_id5(mountpoint, source=dev, dry_run=args.dry_run)
        rollback(
            subvol_main,
            subvol_main_newname,
            subvol_rollback_src,
            dev,
            dry_run=args.dry_run,
        )
    except PermissionError as e:
        LOG.fatal("Permission denied: {}".format(e))
        exit(1)


if __name__ == "__main__":
    main()

sudo chmod +x /usr/local/sbin/snapper-rollback

(this last command may be unnecessary, as the file should already have the necessary permissions)

Now everything should work properly.
At this point, when something is wrong with our system or we simply want to undo some changes, we have two options:

  1. the first is the snapper undochange command - with this command we can cancel the result of a transaction carried out via DNF; since we have set Snapper to take a snapshot before (pre-) and a snapshot after (post-) each transaction, just take note of the ID number of these two snapshots (sudo snapper list) and issue the command
sudo snapper undochange pre-number..post-number
  1. the second is the snapper-rollback command - with this command we can “go back in time” and restore the system to a previous state; to do it
sudo snapper-rollback snapshot-number

(BTW: you can also see what the result of a rollback would be without actually executing it; to do it just add the option --dry-run)

And that’s it! Enjoy!


After the rollback: restoring Snapper

After rebooting into the restored system, Snapper needs to be reconfigured. This is because the new root subvolume doesn’t have a .snapshots subvolume yet, and the old configuration references are stale.

Run these command in order:

sudo rmdir /.snapshots
sudo bash -c 'echo SNAPPER_CONFIGS="" > /etc/sysconfig/snapper'
sudo rm -rf /etc/snapper/configs/root
sudo snapper -c root create-config /

Then reapply your custom Snapper settings:

sudo nano /etc/snapper/configs/root

And restore the configuration exactly as shown in the initial setup section of this guide.

Finally verify everything is working:

sudo snapper list

After the rollback, the old system is preserved as a renamed subvolume (e.g. rootyyy-mm-ddT00:00) with all its snapshots inside. You can safely delete everything with:

sudo mount -o subvolid=5 /dev/* /btrfsroot

My dev/* is /dev/mapper/luks-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx but yours may be different!

sudo btrfs subvolume list /

Delete all snapshot subvolumes first (the ones nested inside the old root), then .snapshots, then the old root itself:

sudo btrfs subvolume delete --subvolid ID /btrfsroot

Repeat for each ID in the correct order: snapshots first, then .snapshots, then the old root last.


Final notes

No guide can ever be 100% accurate and this is no exception. There may be some errors or omissions. So, before performing any steps, first make sure you understand what you are doing. If you notice any errors or omissions or if you have suggestions to improve the guide, comment below the post. Any contribution is welcome!

Thanks for the guide. Ask Fedora > Common Issues is not a place for general guides, though, so I’ll move it to Ask Fedora . The best place for such guides might be at Fedora Docs , particularly Quick Docs . In that case, the guide shouldn’t ideally depend on COPR packages, just regular Fedora repos, I think.

Possibly this type of long and unusual guide should be in Ask Fedora – Start here instead?

Shrug, it doesn’t seem to match the existing content, but I leave that to the forum moderators. My attempt to start a conversation about guides in the forum was here:

Update: the patch for the issue I mentioned doesn’t work properly; I was misled because in --dry-run mode I wasn’t getting any errors; in fact, however, a real rollback is not completed; I’m working on solving the problem!

I followed these steps to add snapper, pre/post snapshots and bootable snapshots and it worked well https://youtu.be/d-CafjZf2M4?si=Mmsvvrg695cWfj-f

Update 2: I corrected all the errors! Now it works perfectly! :tada:

Yes, it’s a great guide. But I tried to make the process as simple as possible, without having to change the default layout of partitions and subvolumes. Furthermore, thanks to the script, you can rollback with a single command in a completely automated way.

This is a great guide, but this setup is way too complex for novice users.

Personally, I don’t really see the need for a / snapshot. System-breaking updates are incredibly rare these days. I’ve run Fedora for years and never had an update completely break my OS. On the rare occasion a new kernel acts up, I just boot an older one from GRUB. Snapshotting the entire root partition feels a bit like over-engineering to me.

Personally, I don’t really see the need for a / snapshot. System-breaking updates are incredibly rare these days

Coming from CachyOS, configuring snapper for root makes a lot more sense then taking a snapshot of your home. You don’t need to include every subdirectory under root, in fact the video I included setup separate subvolumes for cache, logs, vm images, so as not have them part of the snapshots

As for needing it, I see a lot of blackscreen issues after the latest update, and rolling that back, could make sense imo

I tried to do my best.

Same thing for me. I started from Fedora 38 and got to 44 without ever needing a rollback. I’ve never used a snapshot except to test how it works. Let’s say that this setting has more of a precautionary purpose and wants to fully exploit the functionality of the BTRFS filesystem. If someone wants to make changes to the system without the fear of having to reinstall everything from scratch, this is the right solution.

Did anyone have any luck with this guide?

That’s the one I linked to above, I gave the YT version, I see there’s a website version which truth be told, makes more sense.