Tutorials

Fortify Your Old Laptop: A Hands-On Hardened Linux Server Build

Fortify Your Old Laptop: A Hands-On Hardened Linux Server Build

An old laptop already contains several features that small servers often lack: a battery-backed power supply, a screen and keyboard for emergency access, and hardware designed to run quietly. Add disciplined Linux configuration and it can become a capable private server rather than an improvised machine balanced under a desk.

This build uses Debian stable, WireGuard, OpenSSH, nftables, a LUKS-encrypted data container, systemd monitoring, and encrypted off-site backups. After a power failure, the host boots automatically and restores remote administration. The protected data remains locked until an administrator explicitly unlocks it.

The deliberate trade-off is an unencrypted operating system with encrypted application data. It provides unattended recovery without storing the data-volume passphrase on the laptop. An attacker with physical access can inspect or modify the operating system, so use full-disk encryption and measured boot when that threat matters more than automatic recovery.

Prerequisites and architecture

Start with a supported 64-bit laptop, reliable storage, Ethernet if available, and a clean installation of Debian stable. Create an administrative user named admin during installation. Do not expose the machine directly to the public Internet.

You also need a second device for WireGuard administration and a separate SSH-capable backup host. The examples use these documentation addresses:

  • 10.44.0.1: the laptop's WireGuard address
  • 10.44.0.2: the administrator's WireGuard address
  • 51820/udp: WireGuard on the router and laptop
  • 2222/tcp: SSH, reachable only through WireGuard

If the laptop sits behind a router, forward UDP port 51820 to its LAN address. A changing public address requires dynamic DNS. The example client endpoint server.example.net means the DNS name you control; it is not a service supplied by this tutorial.

The resulting configuration is intentionally small:

/etc/wireguard/wg0.conf
/etc/ssh/sshd_config.d/60-hardened.conf
/etc/nftables.conf
/etc/fstab
/etc/systemd/logind.conf.d/server.conf
/etc/systemd/system/vault-health.service
/etc/systemd/system/vault-health.timer
/etc/systemd/system/restic-vault.service
/etc/systemd/system/restic-vault.timer
/usr/local/sbin/vault-open
/usr/local/sbin/vault-health
/var/lib/vault.luks
/srv/vault

Establish the administrative path first

Connect over the trusted LAN and install the base packages. All host commands in this article run on the laptop unless explicitly identified as client commands.

sudo apt update
sudo apt install wireguard nftables cryptsetup openssh-server \
  unattended-upgrades smartmontools restic
sudo systemctl enable --now ssh nftables
sudo systemctl enable --now unattended-upgrades

The backup commands target Restic 0.16.x through 0.18.x. Check the packaged version with restic version before continuing. If your distribution supplies something outside that range, consult that release's documentation instead of assuming identical behavior.

Create an Ed25519 key on the administrator's device with ssh-keygen -t ed25519, then install its public key in /home/admin/.ssh/authorized_keys. Confirm a second key-based SSH session works before disabling passwords.

Build the WireGuard tunnel

Generate the server key as root so its permissions are correct:

sudo install -d -m 0700 /etc/wireguard
sudo sh -c 'umask 077; wg genkey > /etc/wireguard/server.key'
sudo sh -c 'wg pubkey < /etc/wireguard/server.key'
sudo wg genkey | tee client.key | wg pubkey > client.pub

The final two files are temporary client material created in your current directory. Transfer client.key securely to the administrator's device, record client.pub, and remove the local private-key copy after testing.

Use sudoedit /etc/wireguard/wg0.conf and insert the server private key and the contents of client.pub:

[Interface]
Address = 10.44.0.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY

[Peer]
PublicKey = ADMIN_CLIENT_PUBLIC_KEY
AllowedIPs = 10.44.0.2/32

Here, the uppercase values are substitutions for the keys you just generated, not literal configuration. On the administrator's device, configure:

[Interface]
Address = 10.44.0.2/32
PrivateKey = ADMIN_CLIENT_PRIVATE_KEY

[Peer]
PublicKey = SERVER_PUBLIC_KEY
Endpoint = server.example.net:51820
AllowedIPs = 10.44.0.1/32
PersistentKeepalive = 25

Enable the server tunnel with sudo systemctl enable --now wg-quick@wg0. Bring up the client and verify ping 10.44.0.1 before changing SSH or firewall rules.

Constrain SSH and the firewall

Create /etc/ssh/sshd_config.d/60-hardened.conf with sudoedit:

Port 2222
ListenAddress 10.44.0.1
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers admin
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no
MaxAuthTries 3
LoginGraceTime 30

Validate before reloading: sudo sshd -t. Keep the existing session open, run sudo systemctl reload ssh, and establish a new session with ssh -p 2222 [email protected].

On a dedicated new host, preserve the packaged firewall before editing it:

sudo cp --no-clobber /etc/nftables.conf /etc/nftables.conf.factory
sudoedit /etc/nftables.conf

Use this ruleset. Replace enp3s0 with the physical interface reported by ip -brief link.

flush ruleset

table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;

    iifname "lo" accept
    ct state established,related accept
    ct state invalid drop

    ip protocol icmp accept
    ip6 nexthdr ipv6-icmp accept

    iifname "enp3s0" udp dport 51820 accept
    iifname "wg0" ip saddr 10.44.0.0/24 tcp dport 2222 accept
  }

  chain forward {
    type filter hook forward priority 0; policy drop;
  }

  chain output {
    type filter hook output priority 0; policy accept;
  }
}

Run sudo nft -c -f /etc/nftables.conf to check syntax, then sudo systemctl reload nftables. Test WireGuard and a fresh SSH connection again. The input policy now rejects LAN SSH and every unsolicited service except WireGuard.

Create encrypted application storage

A file-backed LUKS container avoids guessing disk device names and accidentally formatting the wrong drive. It is somewhat slower than a dedicated partition and cannot exceed its backing filesystem, but it is easy to size, back up, and remove.

The following creates a new 100 GiB container. First confirm that /var/lib/vault.luks does not exist and that df -h /var/lib shows adequate space. Stop if either condition is false.

sudo test ! -e /var/lib/vault.luks
sudo fallocate --length 100G /var/lib/vault.luks
sudo chmod 0600 /var/lib/vault.luks
sudo cryptsetup luksFormat --type luks2 /var/lib/vault.luks
sudo cryptsetup open /var/lib/vault.luks vault
sudo mkfs.ext4 -L vault /dev/mapper/vault
sudo install -d -m 0750 -o admin -g admin /srv/vault

luksFormat destroys the contents of its target, which is why it must be used only on the newly created container. Choose a long, unique passphrase and store a recovery copy outside the laptop.

Add this exact entry to /etc/fstab:

/dev/mapper/vault /srv/vault ext4 noauto,nosuid,nodev,noexec 0 2

Create /usr/local/sbin/vault-open with sudoedit:

#!/bin/sh
set -eu

if ! cryptsetup status vault >/dev/null 2>&1; then
    cryptsetup open /var/lib/vault.luks vault
fi

if ! mountpoint -q /srv/vault; then
    mount /srv/vault
fi

chown admin:admin /srv/vault
printf '%s\n' "Vault is open and mounted at /srv/vault"

Set sudo chmod 0750 /usr/local/sbin/vault-open. After a reboot, connect through WireGuard, run sudo /usr/local/sbin/vault-open, and enter the passphrase. Services that use this volume should declare RequiresMountsFor=/srv/vault and must not silently write into the empty mount-point directory.

Configure encrypted off-site backups

Create a restricted account named backup on a separate SSH server and an empty directory it owns, such as /srv/restic/laptop. Configure root on the laptop with a dedicated SSH key and pin the backup server's host key in /root/.ssh/known_hosts. Verify that connection interactively before automating it.

Create /etc/restic/vault.env with mode 0600:

RESTIC_REPOSITORY=sftp:[email protected]:/srv/restic/laptop
RESTIC_PASSWORD_FILE=/etc/restic/password

Create /etc/restic/password using sudoedit, place a separate high-entropy repository password inside, and set both files to root:root with mode 0600. Initialize only the new empty repository:

sudo env $(sudo cat /etc/restic/vault.env) restic init
sudo env $(sudo cat /etc/restic/vault.env) restic backup /srv/vault
sudo env $(sudo cat /etc/restic/vault.env) restic snapshots

Because passing parsed environment files through a shell is brittle, automation should let systemd read the file directly. Create /etc/systemd/system/restic-vault.service:

[Unit]
Description=Encrypted backup of the vault
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/vault.env
ExecCondition=/usr/bin/mountpoint -q /srv/vault
ExecStart=/usr/bin/restic backup --one-file-system /srv/vault
ExecStart=/usr/bin/restic forget --keep-daily 7 --keep-weekly 5 --keep-monthly 12
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

Create restic-vault.timer beside it:

[Unit]
Description=Nightly vault backup

[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=20m
Persistent=true

[Install]
WantedBy=timers.target

Run sudo systemctl daemon-reload and sudo systemctl enable --now restic-vault.timer. The low I/O priority keeps backups from dominating an aging disk. Run restic prune separately during a maintenance window because repository compaction can be expensive.

A backup is not proven until it restores. Periodically use restic restore latest --target with a new temporary directory on a machine that has enough space, compare important files, and remove the test copy afterward.

Add observable health checks

Monitoring should distinguish a locked vault from a dead server. Create /usr/local/sbin/vault-health:

#!/bin/sh
set -u
failed=0

for unit in wg-quick@wg0 ssh nftables; do
    systemctl is-active --quiet "$unit" || {
        logger -p daemon.err -t vault-health "$unit is not active"
        failed=1
    }
done

if mountpoint -q /srv/vault; then
    usage=$(df -P /srv/vault | awk 'NR == 2 {gsub("%","",$5); print $5}')
    if [ "$usage" -ge 85 ]; then
        logger -p daemon.warning -t vault-health \
          "vault filesystem is ${usage}% full"
        failed=1
    fi
else
    logger -p daemon.warning -t vault-health "vault is locked or unmounted"
    failed=1
fi

exit "$failed"

Make it executable and define a oneshot service that runs it every five minutes:

[Unit]
Description=Check hardened server health

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/vault-health

The corresponding timer uses OnBootSec=5m, OnUnitActiveSec=5m, and WantedBy=timers.target. After enabling it, inspect results with journalctl -t vault-health and systemctl list-timers. Also schedule smartctl -H against the actual physical drive discovered with lsblk; never assume it is /dev/sda.

Prepare for power and thermal failures

In firmware, enable “restore after AC loss” if available. The laptop battery acts as a small UPS, but a swollen, overheating, or unreliable battery is a hazard rather than resilience. Check cooling, keep vents clear, and monitor storage temperature and SMART history.

Create /etc/systemd/logind.conf.d/server.conf:

[Login]
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore

Apply it with sudo systemctl restart systemd-logind. On this dedicated server, prevent accidental suspension with sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target. This is reversible with systemctl unmask.

Common failure paths

  • WireGuard handshakes but SSH fails: confirm the client routes 10.44.0.1/32, SSH listens on that address, and nftables sees packets on wg0.
  • WireGuard never handshakes: inspect router forwarding, public DNS, the physical interface name, system time, and exchanged public keys.
  • The vault opens but will not mount: inspect cryptsetup status vault, journalctl -k, and fsck.ext4 -n /dev/mapper/vault. The -n performs a read-only check.
  • Restic works interactively but not from systemd: verify root's SSH key, pinned host key, environment-file permissions, DNS, and journalctl -u restic-vault.service.
  • Files appear while the vault is locked: a service wrote into the underlying mount-point directory. Stop it, move those files safely, and add RequiresMountsFor=/srv/vault.

Final verification

  1. Confirm WireGuard and key-only SSH work from outside the home LAN.
  2. Verify LAN connections to TCP 2222 and other unsolicited ports are rejected.
  3. Reboot during a planned maintenance window and confirm remote administration returns while the vault remains locked.
  4. Unlock the vault, start dependent services, and confirm ownership and mount options.
  5. Run a backup, list its snapshot, and complete a test restore.
  6. Inspect health, backup, SSH, WireGuard, kernel, and nftables journals.
  7. Record the LUKS and Restic recovery procedures somewhere that does not depend on this laptop.

The strongest part of this build is not any individual package. It is the separation of responsibilities: WireGuard exposes one narrow doorway, SSH authenticates administrators, LUKS protects data at rest, Restic protects independent copies, systemd makes failures visible, and the unencrypted base system restores access after an outage. That is how an old laptop stops being a clever experiment and starts behaving like infrastructure.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.