Note that recent systemd releases >= 239 (included in Debian 12 aka Bookworm and later) provide a built-in "suspend-then-hibernate" feature documented at https://www.freedesktop.org/software/systemd/man/latest/systemd-suspend-then-hibernate.service.html; prefer that when available.

To use it, set in /etc/systemd/logind.conf the desired entries to the value suspend-then-hibernate, for example

HandleLidSwitch=suspend-then-hibernate
HandleLidSwitchDocked=suspend-then-hibernate
IdleAction=suspend-then-hibernate

The time from sleep till hiberation can be set in a file /etc/systemd/sleep.conf.d/10-suspend-then-hibernate.conf reading, for example

[Unit]
Description=Configure systemd to allow suspend-then-hibernate with a delay of 150 minutes.
Documentation=https://www.freedesktop.org/software/systemd/man/latest/systemd-suspend-then-hibernate.service.html
Documentation=https://www.freedesktop.org/software/systemd/man/latest/systemd-sleep.conf.html
Documentation=https://docs.kernel.org/admin-guide/pm/sleep-states.html#basic-sysfs-interfaces-for-system-suspend-and-hibernation
[Sleep]
# Should be enabled by default
AllowSuspendThenHibernate=yes
HibernateMode=platform shutdown suspend
SuspendEstimationSec=30min
HibernateDelaySec=300min

In case you are bound to systemd versions < 239, we explain below how to have a laptop first Suspend to RAM then after a some time save state to disk and power off completely. Inhibiting a system from suspending/hibernating is explained at Suspend.

Suspend on lid close

In order to trigger suspend on closing the lid, make sure that there is a line in /etc/systemd/logind.conf that reads HandleLidSwitch=suspend, and that it is not commented out (prefixed with #).

The default values are :

Example logind.conf:

# /etc/systemd/logind.conf
[Login]
HandleLidSwitch=suspend
HandleLidSwitchDocked=suspend
# All default lines with comments removed for clarity.

After making the change to logind.conf, run systemctl restart systemd-logind.service. After doing so closing the laptop's lid should cause the laptop to suspend..

If you are running a simple xsession, and want to make sure your screen locks on suspend, you can install the xscreensaver and xss-lock packages, and add the lines:

xscreensaver &
xss-lock -- xscreensaver-command --lock &

to your .xsession-file

From Suspend to Hibernate

When the laptop suspends to ram, it uses very little power, but the current session is not persisted to disk, so if the battery runs out, the system will be forced to do a full boot on resume. There are two ways around this: the classical way is hybrid-sleep: when suspending the machine also writes current state to disk, as with hibernate — so if power runs out no data is lost, and the session can be resumed.

Unfortunately hibernate, even with an solid-state drive, takes longer than entering suspend — and this can be inconvenient at times.

We can use the real-time wake timer to allow the system to wake up from sleep and go straight back to hibernation — if the system has been suspended for a given amount of time. With systemd, we can do this via a service unit; create the file /etc/systemd/system/suspend-sedation.service consisting of the following:

# /etc/systemd/system/suspend-sedation.service
[Unit]
Description=Hibernate after suspend
# Recent systemd releases >= 239 provide a built-in "suspend-then-hibernate" feature; prefer that when available.
# See https://www.freedesktop.org/software/systemd/man/latest/systemd-suspend-then-hibernate.service.html
Documentation=https://bbs.archlinux.org/viewtopic.php?pid=1420279#p1420279
Documentation=https://bbs.archlinux.org/viewtopic.php?pid=1574125#p1574125
Documentation=https://wiki.archlinux.org/index.php/Power_management
Documentation=http://forums.debian.net/viewtopic.php?f=5&t=129088
Documentation=https://wiki.debian.org/SystemdSuspendSedation
Documentation=https://www.freedesktop.org/software/systemd/man/latest/systemd-suspend-then-hibernate.service.html
Conflicts=hibernate.target hybrid-sleep.target
# This unit sets an RTC wake alarm before suspend. On resume it checks whether the wake was due to the alarm; if so, it hibernates, otherwise it treats it as a normal wake and disables the alarm.
Conflicts=hibernate.target hybrid-sleep.target suspend-then-hibernate.target
Before=sleep.target
PartOf=sleep.target
StopWhenUnneeded=true

[Service]
Type=oneshot
RemainAfterExit=yes
SyslogIdentifier=suspend-sedation
Environment="ALARM_SEC=9000"
Environment="WAKEALARM=/sys/class/rtc/rtc0/wakealarm"
Environment="STATE_DIR=/run/suspend-sedation"
Environment="STATE_FILE=/run/suspend-sedation/alarm_set"
EnvironmentFile=-/etc/default/suspend-sedation

# Start section schedules a wake alarm if supported and records state to only act on resume if we actually scheduled it:
#
# - Basic capability checks
# - Swap availability and sizing
# - Required image size: kernel image_size if set, else used RAM * 1.15 heuristic
# - Resume parameter checks (and resume_offset if any swapfile is active)
ExecStart=/bin/sh -c '\
rm -f "$STATE_FILE" 2>/dev/null; \
if ! command -v rtcwake >/dev/null 2>&1; then
  echo "suspend-sedation: rtcwake not found; suspend sedation disabled."
  exit 0
fi
if [ ! -r "$WAKEALARM" ]; then
  echo "suspend-sedation: RTC wakealarm path ($WAKEALARM) not readable; suspend sedation disabled."
  exit 0
fi
if ! grep -qw disk /sys/power/state 2>/dev/null; then
  echo "suspend-sedation: Hibernate not supported by kernel; suspend sedation disabled."
  exit 0
fi
if [ ! -r /proc/swaps ]; then
  echo "suspend-sedation: /proc/swaps not readable; suspend sedation disabled."
  exit 0
fi
HAS_DISK_SWAP=$(awk "NR>1 && \$2 ~ /^(partition|file)$/ && \$1 !~ /^\\/dev\\/zram/ {print 1; exit}" /proc/swaps 2>/dev/null)
if [ "$HAS_DISK_SWAP" != "1" ]; then
  echo "suspend-sedation: No disk-backed swap; suspend sedation disabled."
  exit 0
fi
TOT_SWAP_KiB=$(awk "NR>1 {t+=\$3} END{print t+0}" /proc/swaps 2>/dev/null)
TOT_USED_KiB=$(awk "NR>1 {t+=\$4} END{print t+0}" /proc/swaps 2>/dev/null)
TOT_FREE_BYTES=$(( (TOT_SWAP_KiB - TOT_USED_KiB) * 1024 ))
IMAGE_SIZE=$(cat /sys/power/image_size 2>/dev/null || echo 0)
if [ "${IMAGE_SIZE:-0}" -gt 0 ]; then
  REQ_BYTES="$IMAGE_SIZE"
else
  MEM_TOTAL_KiB=$(awk "/^MemTotal:/ {print \\$2}" /proc/meminfo)
  MEM_AVAIL_KiB=$(awk "/^MemAvailable:/ {print \\$2}" /proc/meminfo)
  MEM_USED_BYTES=$(( (MEM_TOTAL_KiB - MEM_AVAIL_KiB) * 1024 ))
  REQ_BYTES=$(awk -v m="$MEM_USED_BYTES" "BEGIN{printf \"%.0f\\n\", m*1.15}")
fi
if [ "$TOT_FREE_BYTES" -lt "$REQ_BYTES" ]; then
  echo "suspend-sedation: Free swap likely insufficient for hibernation (~$REQ_BYTES B needed, have $TOT_FREE_BYTES B); trying anyway."
fi
RESUME=$(awk "{for(i=1;i<=NF;i++) if (\$i ~ /^resume=/){sub(/^resume=/,\\\"\\\",\\$i); print \\$i; exit}}" /proc/cmdline 2>/dev/null)
if [ -z "$RESUME" ]; then
  echo "suspend-sedation: Missing resume= kernel parameter; suspend sedation disabled."
  exit 0
fi
HAS_SWAPFILE=$(awk "NR>1 && \$2==\\\"file\\\" {print 1; exit}" /proc/swaps 2>/dev/null)
if [ "${HAS_SWAPFILE:-0}" = "1" ]; then
  RESUME_OFFSET=$(awk "{for(i=1;i<=NF;i++) if (\$i ~ /^resume_offset=/){sub(/^resume_offset=/,\\\"\\\",\\$i); print \\$i; exit}}" /proc/cmdline 2>/dev/null)
  if [ -z "$RESUME_OFFSET" ]; then
    echo "suspend-sedation: Active swapfile detected but resume_offset= missing; suspend sedation disabled."
    exit 0
  fi
fi
AS="$ALARM_SEC"; case "$AS" in ""|*[!0-9]*) AS=9000;; esac
echo "suspend-sedation: Scheduling RTC wake in ${AS}s."
if rtcwake -s "$AS" -m no; then
  umask 077; mkdir -p "$STATE_DIR" 2>/dev/null; : > "$STATE_FILE"
else
  echo "suspend-sedation: Failed to schedule RTC wake; suspend sedation disabled."
  rm -f "$STATE_FILE"
fi
'

# Stop section runs on resume; it checks if we set an alarm, determines if the wake was early or due to the alarm, and hibernates when appropriate. It also gracefully handles missing features.
ExecStop=/bin/sh -c '\
if [ ! -e "$STATE_FILE" ]; then \
  echo "suspend-sedation: No prior alarm recorded; skipping."; \
  exit 0; \
fi; \
if [ ! -r "$WAKEALARM" ]; then \
  echo "suspend-sedation: RTC wakealarm path ($WAKEALARM) not readable on resume; skipping."; \
  rm -f "$STATE_FILE"; \
  exit 0; \
fi; \
ALARM=$(cat "$WAKEALARM" 2>/dev/null || true); \
case "$ALARM" in ""|*[!0-9]*) ALARM=0;; esac; \
NOW=$(date +%%s); \
if [ "$ALARM" -ne 0 ] && [ "$NOW" -lt "$ALARM" ]; then \
  echo "suspend-sedation: Woke up before alarm - normal wakeup."; \
  if command -v rtcwake >/dev/null 2>&1; then \
    rtcwake -m disable || true; \
  else \
    echo 0 > "$WAKEALARM" 2>/dev/null || true; \
  fi; \
  rm -f "$STATE_FILE"; \
  exit 0; \
fi; \
echo "suspend-sedation: Woke up - alarm elapsed or not set. Hibernating..."; \
rm -f "$STATE_FILE"; \
systemctl --no-block hibernate || true \
'

[Install]
WantedBy=sleep.target

After creating this file, enable it: sudo systemctl enable suspend-sedation. Now you should be able to enter suspend-mode, either by closing the lid, or via systemctl suspend, and have the system suspend immediately, and be ready to wake up quickly for 5 minutes (300 seconds). After that time, the system should briefly wake up on its own, and immediately hibernate.

In order to see the messages logged by this unit, either use journalctl -u suspend-sedation, or look in /var/log/daemon.log for lines containing "suspend-sedation".

This solution is inspired by: https://bbs.archlinux.org/viewtopic.php?pid=1256340, but uses the rtcwake command from the util-linux package (an essential package, it should be installed on all Debian systems by default). The --auto flag adjusts for the situation where the real-time clock might *not* be set to UTC — this is commonly the case on systems that dual-boot Microsoft Windows, for example.