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
| Task | Command |
|---|---|
| Update packages | apt update && apt upgrade |
| Install package | apt install nginx |
| Check service | systemctl status nginx |
| View logs | journalctl -u nginx -f |
| Reload sysctl | sysctl --system |
| Check listening ports | ss -tlnp |
| Check AppArmor | aa-status |
Table of Contents
- Package Management
- systemd Services
- systemd Timers
- Kernel Tuning
- Security Hardening
- Automatic Updates
- User Management
- 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
| Type | Description | Use When |
|---|---|---|
simple | Default, assumes process stays in foreground | Most services |
exec | Like simple, but waits for exec() | Preferred over simple |
forking | Process forks and parent exits | Legacy daemons |
oneshot | Short-lived tasks | Scripts, setup tasks |
notify | Service signals readiness via sd_notify | Systemd-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:
| Unit | Exposure level | Rating |
|---|---|---|
| The Service Unit Structure example above | 8.3 | EXPOSED |
| The same unit plus the full hardening block | 3.3 | OK |
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
| Pattern | Meaning |
|---|---|
OnCalendar=hourly | Every hour |
OnCalendar=daily | Every day at midnight |
OnCalendar=weekly | Every Monday at midnight |
OnCalendar=*-*-* 02:00:00 | Every day at 2 AM |
OnCalendar=Mon *-*-* 09:00:00 | Every Monday at 9 AM |
OnBootSec=5min | 5 minutes after boot |
OnUnitActiveSec=1h | 1 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:
| Topology | Mode | Setting |
|---|---|---|
| Single uplink, symmetric routing | Strict | rp_filter = 1 |
| Multihomed or multiple default routes | Loose | rp_filter = 2 |
Policy routing, VRF, ip rule marks | Loose | rp_filter = 2 |
| VPN or tunnel with asymmetric return path | Loose | rp_filter = 2 |
| Container or Kubernetes node with overlay networking | Loose | rp_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.all | conf.veth0 | Packets reaching the input hook |
|---|---|---|
| 0 | 0 | 2 of 2 |
| 0 | 1 | 0 of 2 |
| 1 | 0 | 0 of 2 |
| 2 | 0 | 2 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 16777216lowers 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 = 1widens 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_timeoutgoverns orphanedFIN_WAIT_2connections, notTIME_WAIT. Lowering it does not shortenTIME_WAIT, which Linux fixes at the compile-time constantTCP_TIMEWAIT_LEN(60 seconds) and exposes no sysctl for.
Projects MUST follow this sequence before deploying any networking profile:
- Record the keys the profile will change, so the change can be reverted
exactly:
sysctl -a --pattern '^net\.(core|ipv4)\.' > baseline-sysctl.txt - Record the symptom with counters, not intuition:
ss -tin,nstat -az TcpExtListenOverflows TcpExtListenDrops TcpExtTCPBacklogDrop,nstat -az TcpRetransSegs, andip -s linkfor driver-level drops. - Change one profile at a time, in staging, under representative load.
- Compare the same counters and the service’s own latency percentiles.
- 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
| Host | crefft table | Forwarding rules |
|---|---|---|
| Native nftables, no containers | inet crefft as above | Optional forward chain |
| Docker, iptables backend (default) | inet crefft, input chain only | Use the DOCKER-USER iptables chain |
| Docker 29+, nftables backend | inet crefft, input chain only | Own table at the forward hook, plus --bridge-accept-fwmark |
| Docker Swarm | inet crefft, input chain only | iptables backend only; Swarm cannot use nftables |
| firewalld | Manage zones with firewall-cmd | Do 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
- OS Fundamentals — Cross-platform concepts
- SSH — SSH server hardening
- Ansible — Automated Linux configuration
- Docker — Container deployment
- Shell Style Guide — Shell scripting
References
- Debian Administrator’s Handbook
- systemd Documentation
- ArchWiki - Security — Applicable to Debian
- CIS Debian Benchmark