crefft Source 2026.10.4

Linux (Debian) Style Guide

crefft > Infrastructure > OS > Linux

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Targets Debian 13 (Trixie) and Debian 14, with notes for Ubuntu 24.04 LTS.

Quick Reference

TaskCommand
Update packagesapt update && apt upgrade
Install packageapt install nginx
Check servicesystemctl status nginx
View logsjournalctl -u nginx -f
Reload sysctlsysctl --system
Check listening portsss -tlnp
Check AppArmoraa-status

Table of Contents

  1. Package Management
  2. systemd Services
  3. systemd Timers
  4. Kernel Tuning
  5. Security Hardening
  6. Automatic Updates
  7. User Management
  8. Logging

Package Management

APT Best Practices

Projects MUST update the package index before installing packages:

# Always update before install
apt update && apt install nginx

# Never just: apt install nginx (may use stale index)

Sources Configuration

Debian 13+ uses DEB822 format in /etc/apt/sources.list.d/:

# /etc/apt/sources.list.d/debian.sources
Types: deb
URIs: https://deb.debian.org/debian
Suites: trixie trixie-updates
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg

Types: deb
URIs: https://security.debian.org/debian-security
Suites: trixie-security
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg

Third-Party Repositories

Projects MUST use signed repositories with keys in /usr/share/keyrings/:

# Download GPG key
curl -fsSL https://example.com/gpg.key | \
    gpg --dearmor -o /usr/share/keyrings/example-archive-keyring.gpg

# Add repository (DEB822 format)
cat > /etc/apt/sources.list.d/example.sources << 'EOF'
Types: deb
URIs: https://packages.example.com/debian
Suites: stable
Components: main
Signed-By: /usr/share/keyrings/example-archive-keyring.gpg
EOF

apt update

Why: DEB822 format is clearer, supports per-repo signing keys, and is the modern standard replacing one-line sources.list entries.

Package Pinning

Projects MAY pin package versions to prevent unwanted upgrades:

# /etc/apt/preferences.d/nginx
Package: nginx
Pin: version 1.24.*
Pin-Priority: 1000

Useful Commands

# Search packages
apt search nginx

# Show package info
apt show nginx

# List installed packages
apt list --installed

# Show package files
dpkg -L nginx

# Find which package owns a file
dpkg -S /usr/bin/nginx

# Clean package cache
apt clean

# Remove unused dependencies
apt autoremove

systemd Services

Service Unit Structure

Projects MUST follow this structure for service units:

# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
Documentation=https://docs.example.com/myapp
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s

# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/myapp

[Install]
WantedBy=multi-user.target

Service Types

TypeDescriptionUse When
simpleDefault, assumes process stays in foregroundMost services
execLike simple, but waits for exec()Preferred over simple
forkingProcess forks and parent exitsLegacy daemons
oneshotShort-lived tasksScripts, setup tasks
notifyService signals readiness via sd_notifySystemd-aware services

Security Hardening Directives

Projects SHOULD apply security hardening to all services.

Projects MUST NOT put explanatory comments on the same line as a directive. systemd reads the whole remainder of the line as the value, so an annotated assignment fails to parse and the setting is silently discarded. Comments MUST occupy their own line:

# Don't: the comment becomes part of the value and the directive is ignored
NoNewPrivileges=yes          # Prevent privilege escalation
# Do: comment on its own line
# Prevent privilege escalation
NoNewPrivileges=yes

Why: systemd-analyze verify on the annotated form reports Failed to parse NoNewPrivileges=yes # Prevent privilege escalation, ignoring: Invalid argument. Because parsing is non-fatal, the unit still starts — with the protection absent. Measured on Debian 13 (systemd 257) against the hardened unit built from this guide: annotating its directives inline silently discarded 11 of them and raised the exposure level from 3.3 to 4.5.

[Service]
# Privilege restrictions
# Prevent privilege escalation
NoNewPrivileges=yes
# User namespace isolation
PrivateUsers=yes

# Filesystem restrictions
# Mount the whole file system hierarchy read-only
ProtectSystem=strict
# Hide /home, /root and /run/user
ProtectHome=yes
# Private /tmp and /var/tmp
PrivateTmp=yes
# Explicit write access
ReadWritePaths=/var/lib/app
# Deny module loading
ProtectKernelModules=yes
# Deny sysctl writes
ProtectKernelTunables=yes
# Deny cgroup modifications
ProtectControlGroups=yes

# Device restrictions
# Deny device access (except pseudo-devices)
PrivateDevices=yes
# Deny access to /dev nodes
DevicePolicy=closed

# Network restrictions (uncomment only if the service needs no network)
# Isolated network namespace
# PrivateNetwork=yes

# Capability restrictions: drop all, then grant only what is needed
CapabilityBoundingSet=
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

# System call filtering
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallErrorNumber=EPERM

Projects MUST run systemd-analyze verify on every unit before deploying it, and MUST treat Failed to parse output as an error:

systemd-analyze verify /etc/systemd/system/myapp.service

Analyzing Service Security

systemd-analyze security reports an exposure level between 0.0 and 10.0. Lower is better: 0.0 means the tightest sandboxing, and 10.0 means almost no sandboxing is applied.

# Exposure level, 0.0-10.0, lower is better
systemd-analyze security --no-pager myapp.service

# Offline review of a unit file, without querying PID 1
systemd-analyze security --offline=yes --no-pager \
    /etc/systemd/system/myapp.service

Projects MUST NOT treat a high exposure level as a passing result. Projects SHOULD gate CI on a documented maximum exposure with --threshold=, which exits non-zero when the unit is above the limit:

# --threshold takes the internal 0-100 scale: 40 == an exposure level of 4.0
systemd-analyze security --offline=yes --threshold=40 \
    /etc/systemd/system/myapp.service

Target: an exposure level of 4.0 or lower (--threshold=40) for network-facing production services. A service that must keep broader privileges MUST record the agreed maximum and the justification alongside the unit.

Why: measured on Debian 13 (systemd 257), the directives above move a unit from the “EXPOSED” band to the “OK” band:

UnitExposure levelRating
The Service Unit Structure example above8.3EXPOSED
The same unit plus the full hardening block3.3OK

The exposure level scores only the sandboxing systemd itself applies. It is not a vulnerability rating: it says nothing about the application’s own code, its dependencies, or the operations it can request over D-Bus and other IPC.

Socket Activation

Projects SHOULD use socket activation for on-demand services:

# /etc/systemd/system/myapp.socket
[Unit]
Description=My Application Socket

[Socket]
ListenStream=8080
Accept=no

[Install]
WantedBy=sockets.target
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
Requires=myapp.socket

[Service]
Type=exec
ExecStart=/opt/myapp/bin/myapp
# Inherit socket from systemd
StandardInput=socket

Why: Socket activation allows zero-downtime restarts and reduces resource usage for rarely-accessed services.


systemd Timers

Projects SHOULD use systemd timers instead of cron:

Timer Unit

# /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup timer

[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=1h
Persistent=yes

[Install]
WantedBy=timers.target

Service Unit

# /etc/systemd/system/backup.service
[Unit]
Description=Daily backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=backup

Timer Patterns

PatternMeaning
OnCalendar=hourlyEvery hour
OnCalendar=dailyEvery day at midnight
OnCalendar=weeklyEvery Monday at midnight
OnCalendar=*-*-* 02:00:00Every day at 2 AM
OnCalendar=Mon *-*-* 09:00:00Every Monday at 9 AM
OnBootSec=5min5 minutes after boot
OnUnitActiveSec=1h1 hour after last run

Timer Commands

# List active timers
systemctl list-timers

# Enable and start timer
systemctl enable --now backup.timer

# Run service immediately (test)
systemctl start backup.service

# Check timer status
systemctl status backup.timer

Why: Timers have better logging (journalctl), dependency management, resource controls, and randomized delays to prevent thundering herd.


Kernel Tuning

sysctl Configuration

Projects MUST place sysctl settings in /etc/sysctl.d/:

# /etc/sysctl.d/99-custom.conf

# Network security
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1

# IPv6 (if not needed, disable)
# net.ipv6.conf.all.disable_ipv6 = 1

# Memory
vm.swappiness = 10
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5

# File handles
fs.file-max = 2097152
fs.inotify.max_user_watches = 524288

Reverse-path filtering is deliberately absent from this baseline; it is topology-dependent and is covered next.

Reverse-Path Filtering

rp_filter discards packets whose source address does not pass a reverse routing check. The kernel takes the maximum of conf/all/rp_filter and conf/<interface>/rp_filter, so setting net.ipv4.conf.all.rp_filter = 1 forces strict mode on every interface and cannot be relaxed per interface afterwards.

Projects MUST choose the mode from the host’s routing topology rather than applying a blanket value:

TopologyModeSetting
Single uplink, symmetric routingStrictrp_filter = 1
Multihomed or multiple default routesLooserp_filter = 2
Policy routing, VRF, ip rule marksLooserp_filter = 2
VPN or tunnel with asymmetric return pathLooserp_filter = 2
Container or Kubernetes node with overlay networkingLooserp_filter = 2
# Do: leave `all` permissive and set the mode per interface
# /etc/sysctl.d/99-rp-filter.conf
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
net.ipv4.conf.eth0.rp_filter = 1
net.ipv4.conf.wg0.rp_filter = 2
# Don't: this pins every present and future interface to strict mode
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

Where policy routing selects the return path by firewall mark, strict mode MUST be paired with src_valid_mark so the mark is included in the reverse lookup:

net.ipv4.conf.all.src_valid_mark = 1

Projects MUST verify routing after any rp_filter change, before the host carries traffic:

# Confirm the effective value per interface
sysctl net.ipv4.conf.eth0.rp_filter net.ipv4.conf.all.rp_filter

# Which interface would the kernel use to reach a given source address?
# If it is not the interface the packet arrives on, strict mode drops it.
ip route get 203.0.113.10

# Input-path lookup for a packet arriving on a named interface
ip route get 198.51.100.10 from 203.0.113.10 iif eth0

# Count silently discarded martian and unroutable packets
nstat -az | grep -E 'IpInAddrErrors|IpExtInNoRoutes'

Why: strict mode drops a packet that arrives on a legitimate interface which is not the best return path. The kernel documentation recommends strict mode under RFC 3704 for symmetric networks but recommends loose mode “if using asymmetric routing or other complicated routing”. The kernel default is 0, so a blanket 1 is a behavior change, not a hardening of an existing default.

Measured on Linux 6.12 with a veth pair, sending packets whose source address was routable only via a different interface:

conf.allconf.veth0Packets reaching the input hook
002 of 2
010 of 2
100 of 2
202 of 2

Row three is the trap: all = 1 discarded the packets even though the arrival interface was explicitly set to 0.

Network Performance Tuning

Projects MUST NOT change kernel networking defaults without measurement. The Linux defaults are auto-tuned and scale with available memory; a static “performance” block frequently makes throughput worse. Three examples from the values commonly copied between runbooks:

  • net.ipv4.tcp_rmem = 4096 87380 16777216 lowers the initial receive buffer from the kernel default of 131072 bytes and caps auto-tuning at 16 MiB, where the default maximum is between 131072 bytes and 32 MiB depending on RAM.
  • net.ipv4.tcp_tw_reuse = 1 widens the kernel default of 2 (loopback only) to all traffic. The kernel documentation states plainly: “It should not be changed without advice/request of technical experts.”
  • net.ipv4.tcp_fin_timeout governs orphaned FIN_WAIT_2 connections, not TIME_WAIT. Lowering it does not shorten TIME_WAIT, which Linux fixes at the compile-time constant TCP_TIMEWAIT_LEN (60 seconds) and exposes no sysctl for.

Projects MUST follow this sequence before deploying any networking profile:

  1. Record the keys the profile will change, so the change can be reverted exactly: sysctl -a --pattern '^net\.(core|ipv4)\.' > baseline-sysctl.txt
  2. Record the symptom with counters, not intuition: ss -tin, nstat -az TcpExtListenOverflows TcpExtListenDrops TcpExtTCPBacklogDrop, nstat -az TcpRetransSegs, and ip -s link for driver-level drops.
  3. Change one profile at a time, in staging, under representative load.
  4. Compare the same counters and the service’s own latency percentiles.
  5. Keep the change only if the metrics moved; otherwise revert to the baseline.

Profile: High Bandwidth-Delay Product Ingress

Applies only to a host that terminates many long-distance TCP connections (for example a CDN origin or a cross-region reverse proxy) where step 2 showed receive windows limited by buffer size rather than by congestion.

# /etc/sysctl.d/98-network-bdp-ingress.conf
# Prerequisite: measured window-limited transfers; >= 8 GiB RAM.
# Rollback: see below.

# Raise only the auto-tuning ceiling; leave min and default alone so that
# idle sockets keep the kernel's own footprint.
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 131072 16777216
net.ipv4.tcp_wmem = 4096 16384 16777216

Profile: High Connection Arrival Rate

Applies only where step 2 recorded a non-zero and growing TcpExtListenOverflows or TcpExtListenDrops.

# /etc/sysctl.d/98-network-accept-queue.conf
# Prerequisite: measured listen-queue overflows.
# The application must also raise its own listen() backlog; this sysctl
# only raises the ceiling.
net.core.somaxconn = 16384
net.ipv4.tcp_max_syn_backlog = 16384

Memory cost: the middle value of tcp_rmem/tcp_wmem is committed per socket. Raising tcp_wmem’s default from 16 KiB to 64 KiB costs an extra 48 KiB per connection — about 2.3 GiB at 50,000 concurrent connections. Neither profile above raises those middle values for that reason.

Rollback: deleting a drop-in file does not restore the running kernel. sysctl --system only re-applies the files that remain, so the keys the profile set keep their value until they are reset explicitly or the host reboots. Every profile MUST therefore ship the reset alongside it:

rm /etc/sysctl.d/98-network-bdp-ingress.conf

# Restore the recorded values for exactly the keys this profile changed
grep -E '^net\.(core\.[rw]mem_max|ipv4\.tcp_[rw]mem) ' baseline-sysctl.txt \
    | sysctl -p -

sysctl --system

Why: tcp_tw_reuse, tcp_fin_timeout, tcp_slow_start_after_idle, and the keepalive timers change TCP’s correctness-relevant behavior, not just its buffer sizes, and the kernel documentation asks for expert justification before they are touched. Publishing them as a default profile turns a workload-specific experiment into an unreviewed change on every host.

Applying Changes

# Apply all sysctl.d settings
sysctl --system

# Apply specific file
sysctl -p /etc/sysctl.d/99-custom.conf

# Verify setting
sysctl net.ipv4.tcp_syncookies

Security Hardening

AppArmor

Debian uses AppArmor by default. Projects SHOULD create profiles for custom applications:

# Check AppArmor status
aa-status

# Generate profile skeleton
aa-genprof /usr/bin/myapp

# Set profile to complain mode (log but don't block)
aa-complain /etc/apparmor.d/usr.bin.myapp

# Set profile to enforce mode
aa-enforce /etc/apparmor.d/usr.bin.myapp

# Reload profiles
apparmor_parser -r /etc/apparmor.d/

Example AppArmor Profile

# /etc/apparmor.d/opt.myapp.bin.myapp
#include <tunables/global>

/opt/myapp/bin/myapp {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  # Binary
  /opt/myapp/bin/myapp mr,

  # Libraries
  /opt/myapp/lib/** mr,

  # Configuration (read-only)
  /etc/myapp/** r,

  # Data directory (read-write)
  /var/lib/myapp/** rw,

  # Logs
  /var/log/myapp/** w,

  # Network
  network inet stream,
  network inet dgram,

  # Deny everything else implicitly
}

SSH Hardening

See SSH Service Guide for comprehensive SSH hardening.

Quick checklist:

  • Disable root login
  • Key-based authentication only
  • Non-standard port (optional)
  • AllowUsers/AllowGroups configured
  • fail2ban installed

Firewall (nftables)

Debian 13+ uses nftables (Debian 13 ships nftables 1.1.3). Projects MUST configure a default-deny input policy.

Projects MUST NOT use flush ruleset in a host firewall file. Docker, firewalld, libvirt, and Kubernetes CNI plugins install and own their own nftables tables; flushing destroys them, taking published container ports and NAT rules with them. The host MUST own exactly one named table and MUST leave every other table alone.

# /etc/nftables.conf
#!/usr/sbin/nft -f

# Replace only this file's own table. Never `flush ruleset`.
destroy table inet crefft

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

        # Allow established/related, drop invalid
        ct state established,related accept
        ct state invalid drop

        # Allow loopback
        iif lo accept

        # IPv4 ICMP
        meta l4proto icmp icmp type {
            echo-request, destination-unreachable,
            time-exceeded, parameter-problem
        } accept

        # IPv6 ICMP: `meta l4proto` matches through extension headers
        meta l4proto ipv6-icmp icmpv6 type {
            destination-unreachable, packet-too-big,
            time-exceeded, parameter-problem,
            echo-request, echo-reply,
            mld-listener-query, mld-listener-report,
            mld-listener-done, mld2-listener-report
        } accept

        # Neighbor and router discovery: link-local only
        meta l4proto ipv6-icmp icmpv6 type {
            nd-router-solicit, nd-router-advert,
            nd-neighbor-solicit, nd-neighbor-advert
        } ip6 hoplimit 255 accept

        # Allow SSH (adjust port as needed)
        tcp dport 22 accept

        # Allow HTTP/HTTPS
        tcp dport { 80, 443 } accept

        # Log dropped packets (optional, can be noisy)
        # log prefix "nftables dropped: " counter drop
    }

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

Matching ICMPv6

Projects MUST NOT match ICMPv6 with ip6 nexthdr icmpv6. nexthdr reads only the first Next Header field, so any ICMPv6 message carrying an extension header — including the hop-by-hop header that MLD queries and reports use — misses the rule and reaches the drop policy.

# Don't
ip6 nexthdr icmpv6 accept

# Do
meta l4proto ipv6-icmp accept

Why: measured on Linux 6.12 with nftables 1.1.3, two counters in the same input chain saw different traffic. Three plain ICMPv6 echo requests plus their replies matched both rules (6 packets each). Three echo requests carrying a hop-by-hop header matched meta l4proto ipv6-icmp 6 times but ip6 nexthdr icmpv6 only 3 — the requests were missed. Under the default-deny policy above, sending the hop-by-hop request across a veth pair produced no reply with nexthdr, and a normal reply with meta l4proto.

No Blanket Forward Chain

The example above has no forward base chain. Projects MUST NOT add a forwarding drop policy to a host that runs a container engine.

Why: nftables runs every base chain registered at a hook. An accept in one table does not stop another table’s chain from dropping the packet, and Docker’s own documentation states that “an ‘accept’ rule is not final. It terminates processing for its base chain, but the accepted packet will still be processed by other base chains, which may drop it.” Measured on Linux 6.12: with a single accept-policy input chain, ping 127.0.0.1 succeeded; after adding an empty second table whose input chain had policy drop at the same hook, the same ping was blocked.

A host that genuinely routes between interfaces and runs no container engine MAY add a forward chain to its own table:

    chain forward {
        type filter hook forward priority filter; policy drop;
        ct state established,related accept
        iifname "eth0" oifname "eth1" accept
    }

Container and Firewall Manager Variants

Hostcrefft tableForwarding rules
Native nftables, no containersinet crefft as aboveOptional forward chain
Docker, iptables backend (default)inet crefft, input chain onlyUse the DOCKER-USER iptables chain
Docker 29+, nftables backendinet crefft, input chain onlyOwn table at the forward hook, plus --bridge-accept-fwmark
Docker Swarminet crefft, input chain onlyiptables backend only; Swarm cannot use nftables
firewalldManage zones with firewall-cmdDo not hand-write /etc/nftables.conf

Docker’s nftables backend, introduced in Docker 29.0.0, is experimental and cannot be enabled while the daemon runs in Swarm mode. It owns the ip docker-bridges and ip6 docker-bridges tables, which MUST NOT be edited directly. There is no DOCKER-USER chain in that mode; host rules go in a separate table with base chains at the same hooks, ordered by base chain priority. Because Docker’s drop rules are final, overriding them requires a firewall mark and the daemon option --bridge-accept-fwmark.

With the iptables backend, Docker sets the iptables FORWARD policy to DROP and provides the DOCKER-USER chain, which is processed before Docker’s own rules:

# Restrict which sources may reach published container ports
iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.0/24 -j DROP

Applying Rules Safely

Projects MUST validate before applying, and MUST keep a way back in.

# 1. Syntax and semantic check; makes no change to the running ruleset
nft --check -f /etc/nftables.conf

# 2. Snapshot the current state of the crefft table as a replayable file
{ echo 'destroy table inet crefft'
  nft list table inet crefft 2>/dev/null || true
} > /root/nftables-rollback.conf

# 3. Schedule an automatic rollback before applying, in case SSH is cut off
systemd-run --on-active=120 --unit=nft-rollback \
    /usr/sbin/nft -f /root/nftables-rollback.conf

# 4. Apply
nft -f /etc/nftables.conf

# 5. Confirm the session still works, then cancel the rollback
systemctl stop nft-rollback.timer

# Enable on boot
systemctl enable nftables

# List current rules
nft list ruleset

Why: destroy table is idempotent, so re-running the file replaces only the crefft table. Verified on Debian 13: with simulated ip docker and ip firewalld_sim tables present, applying a file that begins with flush ruleset left only inet filter, whereas the file above left both application tables intact and could be re-applied without error.

File Integrity

Projects SHOULD use AIDE or similar for file integrity monitoring:

# Install AIDE
apt install aide

# Initialize database
aideinit

# Check for changes
aide --check

Automatic Updates

unattended-upgrades

Projects MUST enable automatic security updates:

apt install unattended-upgrades apt-listchanges

# Enable
dpkg-reconfigure -plow unattended-upgrades

Configuration

Projects MUST select repositories with Origins-Pattern and match on codename, not on archive. The legacy Allowed-Origins shorthand "${distro_id}:${distro_codename}" expands to o=Debian,a=trixie, but Debian’s archive field carries the suite name (stable, stable-security, stable-updates) and the codename lives in a separate field. The shorthand therefore matches nothing.

# Don't: matches no Debian repository, including security
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}";
    "${distro_id}:${distro_codename}-security";
    "${distro_id}:${distro_codename}-updates";
};
# /etc/apt/apt.conf.d/50unattended-upgrades
# Do: codename-based selectors, tested against the live archive.
Unattended-Upgrade::Origins-Pattern {
    // Security updates. Both entries are required: the security archive
    // has used both the base codename and the -security codename.
    "origin=Debian,codename=${distro_codename},label=Debian-Security";
    "origin=Debian,codename=${distro_codename}-security,label=Debian-Security";

    // Point releases and stable-updates. Remove these two lines to apply
    // security fixes only; that choice MUST be deliberate and recorded.
    "origin=Debian,codename=${distro_codename},label=Debian";
    "origin=Debian,codename=${distro_codename}-updates,label=Debian";
};

Unattended-Upgrade::Package-Blacklist {
    // "linux-";
    // "postgresql-";
};

Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Automatic-Reboot-Time "02:00";

// Email notifications
Unattended-Upgrade::Mail "admin@example.com";
Unattended-Upgrade::MailReport "only-on-error";

This file replaces the one Debian ships, which contains the only Origins-Pattern entries on a default install. Any pattern removed here is removed from the system; nothing else re-adds it.

Why: the archive metadata and the codename metadata are different fields. On Debian 13 the three repositories publish a=stable,n=trixie,l=Debian, a=stable-updates,n=trixie-updates,l=Debian, and a=stable-security,n=trixie-security,l=Debian-Security. Archive-based selectors also follow the suite rather than the release, so a=stable silently starts upgrading to Debian 14 when trixie stops being stable.

# Read the metadata this host actually publishes
apt-cache policy
 500 http://deb.debian.org/debian-security trixie-security/main arm64 Packages
     release v=13,o=Debian,a=stable-security,n=trixie-security,l=Debian-Security
 500 http://deb.debian.org/debian trixie/main arm64 Packages
     release v=13.6,o=Debian,a=stable,n=trixie,l=Debian
# /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";

Testing

Projects MUST confirm the configured origins actually match a repository before relying on unattended upgrades:

# Dry run
unattended-upgrades --dry-run --debug

# Check logs
cat /var/log/unattended-upgrades/unattended-upgrades.log

Read two things in that output. Allowed origins are: echoes the expanded patterns, and any repository that failed to match is reported as Marking not allowed <...> with -32768 pin. A correct configuration produces no Marking not allowed line for a repository that should be upgraded:

Allowed origins are: origin=Debian,codename=trixie,label=Debian-Security,
  origin=Debian,codename=trixie-security,label=Debian-Security,
  origin=Debian,codename=trixie,label=Debian,
  origin=Debian,codename=trixie-updates,label=Debian

With the legacy shorthand, the same host instead reports every repository as excluded:

Allowed origins are: o=Debian,a=trixie, o=Debian,a=trixie-security,
  o=Debian,a=trixie-updates
Marking not allowed <... a=stable-security,l=Debian-Security ...> with -32768 pin
Marking not allowed <... a=stable-updates,l=Debian ...> with -32768 pin
Marking not allowed <... a=stable,l=Debian ...> with -32768 pin

User Management

Creating Users

# Interactive user
useradd -m -s /bin/bash -G sudo username

# Service account (no shell, no home)
useradd --system --shell /usr/sbin/nologin --home-dir /nonexistent servicename

sudo Configuration

Projects MUST use /etc/sudoers.d/ for custom sudo rules:

# /etc/sudoers.d/admins
# Group-based sudo access
%admins ALL=(ALL:ALL) ALL

# Specific user, no password for specific commands
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp
# Validate syntax before saving
visudo -c -f /etc/sudoers.d/admins

Password Policy

Projects MUST NOT impose composition rules or scheduled password expiry. Both were removed from NIST SP 800-63B, which now requires a 15-character minimum for single-factor passwords, forbids other composition rules, forbids periodic rotation, and requires a compromised-password blocklist plus rate limiting.

Enable the PAM module first. Editing pwquality.conf alone changes nothing: the settings are only read when pam_pwquality.so is in the password stack.

apt install libpam-pwquality libpwquality-tools cracklib-runtime

# Debian's pam-auth-update adds pam_pwquality to the password stack
pam-auth-update

# Verify the module is actually active
grep pam_pwquality /etc/pam.d/common-password
# /etc/security/pwquality.conf
minlen = 15         # single-factor minimum; 8 is permitted only with MFA
maxrepeat = 0       # disabled: repeat limits are a composition rule
minclass = 0        # disabled: character-class rules are forbidden
dcredit = 0
ucredit = 0
lcredit = 0
ocredit = 0
dictcheck = 1       # reject blocklisted and dictionary passwords
usercheck = 1       # reject passwords derived from the username
enforcing = 1
retry = 3

Maintain the blocklist as a cracklib dictionary. On Debian, update-cracklib rebuilds the dictionary from every text file under /usr/share/dict, so the breach corpus is merged with the standard word lists rather than replacing them:

# One password per line, from a maintained compromised-password corpus
install -m 0644 compromised-passwords /usr/share/dict/compromised-passwords
update-cracklib

# Confirm the policy rejects a blocklisted value and accepts a passphrase
echo 'correcthorsebatterystaple' | pwscore
echo 'gorse-lintel-quarry-pelican' | pwscore

Rate-limit authentication failures with pam_faillock. Debian ships the module but does not enable it, so /etc/pam.d/common-auth MUST be edited to bracket the authentication modules:

# /etc/pam.d/common-auth
auth    required                        pam_faillock.so preauth
auth    [success=1 default=ignore]      pam_unix.so nullok
auth    [default=die]                   pam_faillock.so authfail
auth    sufficient                      pam_faillock.so authsucc
auth    requisite                       pam_deny.so
auth    required                        pam_permit.so
# /etc/security/faillock.conf
deny = 5
fail_interval = 900
unlock_time = 900
even_deny_root
root_unlock_time = 60
# Inspect and clear lockouts
faillock --user alice
faillock --user alice --reset

Disable scheduled expiry, and force a change only on evidence of compromise:

# /etc/login.defs — applies to accounts created after this change
PASS_MAX_DAYS   99999
PASS_MIN_DAYS   0
PASS_WARN_AGE   7
# login.defs is NOT retroactive; existing accounts keep their own aging
chage --maxdays 99999 --mindays 0 alice

# Force a change after evidence of compromise
chage -d 0 alice

Why: verified on Debian 13 with libpwquality 1.4.5. Under the previous minlen = 14, minclass = 3 policy, pwscore rejected both correcthorsebatterystaple and gorse-lintel-quarry-pelican for containing “less than 3 character classes” while accepting Summer2026!London — the policy rejected strong passphrases and admitted a guessable pattern. Under the policy above, the two passphrases score 100 unless they appear in the blocklist, and blocklisted values are rejected with “the password fails the dictionary check”. pam_faillock was confirmed to reject the correct password after five failures with “15 minutes left to unlock”, and chage -l confirmed that changing login.defs left an existing account’s 90-day maximum untouched until chage was run explicitly.

PASS_MIN_DAYS 1 is deliberately dropped: a minimum age prevents a user from changing a password twice in one day, which blocks remediation immediately after a forced reset.


Logging

journald Configuration

# /etc/systemd/journald.conf
[Journal]
Storage=persistent
Compress=yes
SystemMaxUse=2G
SystemMaxFileSize=100M
MaxRetentionSec=90day
ForwardToSyslog=no
# Apply changes
systemctl restart systemd-journald

Useful Journal Commands

# Follow all logs
journalctl -f

# Specific unit
journalctl -u nginx

# Since boot
journalctl -b

# Specific time range
journalctl --since "2024-01-01" --until "2024-01-02"

# Kernel messages
journalctl -k

# Priority filtering
journalctl -p err    # Errors and above

# JSON output
journalctl -o json-pretty

# Disk usage
journalctl --disk-usage

# Cleanup
journalctl --vacuum-time=30d
journalctl --vacuum-size=1G

Remote Logging

For centralized logging, configure rsyslog or use systemd-journal-remote:

# /etc/systemd/journal-upload.conf
[Upload]
URL=https://logs.example.com:19532

See Also


References