Setting Up a Bare Debian Server with Ansible: Hardening, Unattended Upgrades, Backups, and Docker
You have just booted a fresh Debian server. Root login over SSH with a password, no firewall to speak of, no packages beyond the base install, and a mental list of things you know you should do before this box touches the internet. I have run that list by hand enough times to know it does not scale, and the moment you manage a second server, the argument for automation stops being theoretical.
This post is the playbook I use to take a bare Debian install to a secure, usable server host: a deploy user with SSH key auth, unattended-upgrades for security patches, restic backups on a systemd timer, and Docker Engine installed from the official repository so I can drop a Compose stack on top. More important than the YAML is the reasoning behind each step, because every one of these decisions is a trade-off, and the defaults that sound safe are often not the defaults you want.
The short version
- Ansible earns its keep from the first playbook, not the tenth. A setup playbook is where the return on investment is highest, because it turns a 40 minute session of clicking and remembering into a repeatable, reviewable artifact.
- SSH hardening happens first, and it is the highest-leverage security change on the box. No password auth, no root login, one deploy user with a key. Everything else assumes this is in place.
- unattended-upgrades defaults to security-only, which is exactly what you want. The trap is the automatic reboot policy and the third-party repositories you forgot to allow or exclude.
- Backups are restic to an offsite target with a systemd timer, not a cron job. Deduplication, encryption, and snapshots beat rsync for anything you will need to restore a month later.
- Docker comes from the official apt repository with a signed-by keyring, never the convenience script. The docker group is effectively root, and I do not put a deploy user in it.
- Secrets never live in the playbook. Ansible Vault or a 0600 .env file, and nothing else.
- Debian
13 (Trixie), point release 13.5 - ansible-core
current stable - Docker Engine
stable channel via download.docker.com - unattended-upgrades
current Debian package - restic
current stable
Checked 2026-08-13 against Debian release announcements (Trixie is current stable; 13.5 shipped May 2026), the official Docker Engine install docs for Debian, the Debian wiki PeriodicUpdates page, and the restic docs. Re-checked 2026-09-14: Docker repository architecture names and Compose secrets wiring against docs.docker.com, and the Debian ssh.service unit name against Debian openssh packaging. Versions and paths in this post reflect those sources.
What we are building
A single playbook project that you can run against one fresh box today and a fleet next quarter. The layout is deliberately boring:
server-setup/
├── ansible.cfg
├── inventory/hosts.ini
├── playbooks/setup.yml
├── group_vars/all.yml
├── files/
│ └── deploy.pub
└── roles/
├── base/
├── sshd/
├── unattended-upgrades/
├── backups/
└── docker/Each concern gets its own role so the playbook reads like a checklist instead of a wall of tasks. The setup playbook just includes them in order:
- name: Bring a bare Debian host to baseline
hosts: all
become: true
roles:
- base
- sshd
- unattended-upgrades
- backups
- dockerWhy Ansible instead of a shell script
A bash script can do all of this, so why Ansible? Three reasons, in order of importance.
First, idempotency. A script that installs packages and edits configs will happily re-run and make things worse, or report success while doing nothing. Ansible tasks declare desired state, so running the playbook twice is as safe as running it once, and --check mode shows you the diff before you commit to it.
Second, it is a reviewable artifact. A playbook is a code review of your server setup. A colleague (or future you) can look at the diff and see exactly what changed and why, instead of reconstructing intent from shell history.
Third, it scales to many hosts. The same roles run against one VPS or twenty, and the inventory is just a file. The cost is a control machine, which can be your laptop, and a small learning curve. For a single box, the trade is still worth it, because the playbook is the documentation of how that box should look.
The honest trade-off: Ansible adds a layer of abstraction and a dependency on a control node. If you will never manage more than one server and never reinstall it, a script is fine. Most people are not that person, and they find out the hard way.
Step 1: Bootstrap and SSH hardening
The fresh box comes up with root and password auth enabled, because that is how you logged in to install it. The very first thing the playbook does is close that door, in a careful order so you do not lock yourself out.
The flow: connect as root (or with your existing sudo user), create a dedicated deploy user, install your public key for that user, then tighten sshd. Password auth and root login are disabled last, after the key is confirmed present.
# roles/sshd/tasks/main.yml
- name: Create deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: sudo
append: true
shell: /bin/bash
create_home: true
- name: Install SSH key for deploy user
ansible.builtin.authorized_key:
user: "{{ deploy_user }}"
key: "{{ lookup('file', 'files/deploy.pub') }}"
- name: Harden sshd
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?{{ item.option }}"
line: "{{ item.option }} {{ item.value }}"
loop:
- { option: "PermitRootLogin", value: "prohibit-password" }
- { option: "PasswordAuthentication", value: "no" }
- { option: "KbdInteractiveAuthentication", value: "no" }
- { option: "PubkeyAuthentication", value: "yes" }
notify: restart sshdThe choices here are deliberate. PermitRootLogin prohibit-password keeps root login possible with a key, which is useful for rescue situations, while killing the password path. PasswordAuthentication no removes the entire class of brute-force and credential-stuffing attacks against the SSH port, which is a bigger win than any fail2ban setup. Keep PubkeyAuthentication yes and nothing else that lets a password in.
One piece of plumbing the task above hides: notify: restart sshd names a handler, and that handler has to restart the right unit. Debian's OpenSSH package ships the unit as ssh.service; sshd is only a compatibility alias, and the real sshd.service name belongs to RHEL-family systems. A handler that hardcodes sshd happens to work on most Debian boxes (the alias exists once the service is enabled) and then fails with Could not find the requested service sshd: host on the one box where the alias was never created. This playbook targets Debian, so the handler is two lines:
# roles/sshd/handlers/main.yml
- name: restart sshd
ansible.builtin.service:
name: ssh
state: restartedThe handler's name can stay restart sshd; it only has to match the notify. What matters is the service name inside it, and if you ever target mixed distributions that name becomes a per-OS variable.
One more detail worth naming: I put the deploy user in the sudo group. That is a convenience with a cost. Any compromise of that user escalates to root with a single sudo password (or NOPASSWD if you configure it, which I do not). For a small operation it is the right trade; for anything with real security requirements, you would carve that user down to specific sudo rules.
Step 2: Base system baseline
The base role is unglamorous but foundational: update the package lists, upgrade everything once, set the timezone, and make journald persistent so logs survive a reboot.
# roles/base/tasks/main.yml
- name: Run apt update
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Upgrade all packages
ansible.builtin.apt:
upgrade: safe
- name: Install base packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
- git
- rsync
- htop
state: present
- name: Set timezone
ansible.builtin.timezone:
name: "{{ server_timezone }}"
- name: Make journald persistent
ansible.builtin.lineinfile:
path: /etc/systemd/journald.conf
regexp: "^#?Storage="
line: "Storage=persistent"
notify: restart journaldA note on upgrade: safe: this is the distro-safe upgrade mode, which does not remove or install new packages, so it will not yank a service out from under you. It is also the last time this playbook does a full upgrade, because from here the upgrade story is unattended-upgrades' job, for security patches, plus your own hands for the rest.
Why persistent journald? On a fresh Debian, the journal lives in memory and dies with the box. If a service crashes at 3 AM, the evidence of why is usually in the journal. Storage=persistent is a one-line change that makes logs survive, and it costs almost nothing. This is the kind of step that looks optional until the day it is the only reason you can diagnose an outage.
Step 3: Unattended-upgrades
The gap between “I should update this server” and “I will update this server” is where real-world breaches happen. Debian's answer is unattended-upgrades, which can automatically install updates on a schedule. The default configuration is genuinely good: it only installs updates from allowed origins, and the default origin is the security repository. That is exactly the right posture, security patches automatically, everything else reviewed by a human.
The role does three things: installs the package, writes a config that keeps the security-only default but adds the behaviors I want, and enables the apt periodic timer.
# roles/unattended-upgrades/tasks/main.yml
- name: Install unattended-upgrades and apt-listchanges
ansible.builtin.apt:
name:
- unattended-upgrades
- apt-listchanges
state: present
- name: Enable periodic apt jobs
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
- name: Configure unattended-upgrades
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/50unattended-upgrades
content: |
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
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";Now the trade-offs, because this is where the defaults stop being obviously right.
Reboot policy. Kernel updates cannot be applied to a running kernel. You have three choices: reboot automatically (dangerous on a box that should never go down, surprising at 2 AM), reboot with a maintenance window (good for a box you control the load on), or never reboot and accept that kernel fixes wait until you schedule one. I default to Automatic-Reboot false and rely on a reboot I schedule myself, because a server that reboots itself mid-service is worse than one that is a week behind on a kernel fix. For a load-balanced or disposable fleet, automatic reboot becomes attractive again. Your call, but make it consciously.
Allowed origins. I pin the config to only the -security origin. Docker's repository, if added, is a separate origin and will not be touched by unattended-upgrades unless you allow it, which is a feature: third-party repos are exactly where an unattended auto-update can break your stack. If you want them auto-updated, you add them explicitly and accept the risk.
Notifications. unattended-upgrades can email you via Unattended-Upgrade::Mail, but a headless box needs an MTA configured for that to work, which is real complexity. The pragmatic middle ground is to check /var/log/unattended-upgrades/ when you are on the box, or wire a small log-watcher into your monitoring. I keep it simple and inspect the log on a schedule.
Step 4: Backups with restic on a systemd timer
Backups are the step everyone claims to have and almost nobody has tested. The rule I operate on: a backup you have never restored is a hope, not a backup. So this step is built around a tool and a schedule that make restoring cheap enough to actually test: restic, with encrypted, deduplicated snapshots pushed offsite.
Why restic over rsync or a raw copy? rsync gives you a mirror, not history: one bad sync and your good data is overwritten. restic gives you snapshots, deduplication, and encryption. Deduplication matters because daily backups of a mostly-unchanged server cost almost nothing; encryption matters because the backup lives on someone else's disk (S3, B2, an SFTP host) and should be unreadable there. The alternative worth naming is borg, which is excellent and arguably better for a single repo you manage by hand. I reach for restic when I want S3-compatible targets and simple retention flags in one tool.
What to back up is a decision, not a default. I back up /etc (the entire system config, which is where the Ansible roles above live on disk), the directory where my Compose projects and their bind-mounted data live (/srv/docker, the same tree Step 6 deploys into), and any service state that is not in a database. Databases are special: they get their own dump first, because backing up a live Postgres data directory while it is writing is how you get a corrupt restore. A small script in the role dumps the databases, then restic picks up the dumps. The --exclude in the unit file keeps the live Postgres data directory out of the file-level backup, because the dump is the consistent copy.
The schedule uses a systemd timer, not cron, and the reason is subtle: systemd timers run missed jobs on boot (Persistent=true), so a server that was off at backup time still gets its backup when it comes back. Cron silently skips. That single property is why timers are the right default on any modern systemd distro.
# roles/backups/files/restic-backup.service
[Unit]
Description=Run restic backup
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/restic/restic.env
ExecStart=/usr/local/bin/restic backup /etc /srv/docker \
--exclude /srv/docker/*/data/postgres
ExecStartPost=/usr/local/bin/restic forget \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune# roles/backups/files/restic-backup.timer
[Unit]
Description=Daily restic backup
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=900
[Install]
WantedBy=timers.targetThe forget --prune line is the retention policy in one place: 7 daily, 4 weekly, 6 monthly snapshots, and anything older is deleted. That is a sane default for a small server. If the data is valuable enough, that is also where you add a second target for a second copy, because the 3-2-1 rule (three copies, two media, one offsite) is the thing that survives a fire at the colo.
And the part everyone skips: test the restore. restic restore latest --target /tmp/restore-test takes minutes, and it is the only way to know the backup is actually the thing you think it is. Do it the week you set this up, and then on a quarterly schedule, and treat a failed restore as the emergency it is.
Step 5: Docker Engine, from the official repo, pinned
Docker is where most tutorials point you at the convenience script (curl | sh), and I want to be explicit about why this playbook does not. The convenience script is fine for a laptop and terrible for a server you manage with Ansible: it is not idempotent-friendly, it does not integrate with your apt lifecycle, and you are executing a remote script as root with no review. The official apt repository gives you the same packages through the mechanism your whole system already uses, so updates, removal, and dependency handling behave like everything else.
The other modern detail: the GPG key goes in /etc/apt/keyrings and the repository references it with signed-by. The old apt-key add approach is deprecated because it installed keys globally and trusted them for every repo, which is exactly the wrong security posture for a machine that is supposed to be hardened. A signed-by pin means this repo is authenticated by this key, and nothing else.
# roles/docker/tasks/main.yml
- name: Add Docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Add Docker repository
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ docker_apt_arch }} signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/debian
{{ ansible_distribution_release }} stable
filename: docker
- name: Install Docker Engine and plugins
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: trueTwo decisions in this step are worth spelling out.
First, the arch label in the repository line. ansible_architecture reports kernel names (x86_64, aarch64, armv7l), while Docker's repository serves Debian package names (amd64, arm64, armhf). The fix that looks right, arch={{ ansible_architecture | lower }}, is quietly wrong in both directions: lowercased, x86_64 is still x86_64 and aarch64 is still aarch64, and the repository publishes neither. apt skips the repo with a notice (Skipping acquire of configured file 'stable/binary-x86_64/Packages' ... doesn't support architecture 'x86_64'), the install task then fails with No package matching 'docker-ce' is available, and you are an hour deep in exactly the silent-wrong failure mode this playbook exists to prevent. Map the names explicitly:
# roles/docker/defaults/main.yml
docker_arch_map:
x86_64: amd64
aarch64: arm64
armv7l: armhf
docker_apt_arch: "{{ docker_arch_map[ansible_architecture] }}"That is the same mapping the well-known Docker roles carry. The alternative is dpkg --print-architecture, which prints the Debian name directly and is what Docker's own install docs put in their repo line. Either works; what does not work is a lower-case filter on a kernel name. An unmapped architecture fails loudly with a key error, which beats a silently skipped repository.
Second, and most important: I do not add the deploy user to the docker group. The docker group is effectively root, because the daemon socket can mount host paths and run privileged containers. Adding a user to it is a convenience that erases the boundary this whole playbook has been building. Instead, Docker is used the way a server admin uses it: the deploy flow runs with sudo, or better, the Compose project is deployed by a process that only has the rights it needs. If you want the containers themselves to be less privileged, that is a whole other axis, and my Docker vs Podman comparison covers the rootful vs rootless decision in depth.
One more reason to prefer the apt route that people miss: because docker-ce is an apt package, it gets security updates through the same mechanisms as the rest of the system. It will not be auto-updated by unattended-upgrades unless you allow the Docker origin, which is the right default, but it is updated and version-pinned like everything else, and my release-tracking workflow is how I keep an eye on when a new version is worth pulling deliberately.
Step 6: Deploying a stack with Docker Compose
With Docker Engine in place, the last role deploys the actual workload. On a single box, Compose is the right tool, and my Compose vs Kubernetes analysis makes the argument for why a cluster is a second job, not an upgrade, at this scale.
The project lives under /srv/docker/<project> on the host, with container state on bind mounts under data/ inside the project directory. That placement is deliberate: the restic job in Step 4 backs up host paths under /srv/docker, while named volumes live in /var/lib/docker/volumes/<project>_db-data/_data, where a path-based backup never sees them. Bind mounts keep the data where the backup already looks. A minimal, representative stack looks like this:
# /srv/docker/myapp/compose.yaml
services:
app:
image: myregistry.example.com/myapp:latest
restart: unless-stopped
env_file: .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./data/app:/var/lib/app
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- ./data/postgres:/var/lib/postgresql/data
secrets:
db_password:
file: ./secrets/db_passwordThree details here are the difference between a stack that runs and a stack that survives.
`restart: unless-stopped` means the container comes back after a daemon restart or a crash, but stays down if you explicitly stopped it. That is the right default for a server workload; always would resurrect a container you deliberately killed.
The healthcheck is what turns Docker from “it started” into “it works”. Compose waits for it, and other services can depend on it. A container that binds a port but crashes on every request is “running” by Docker's default definition; the healthcheck is the honest signal.
The port binding is on loopback (127.0.0.1:8080:8080), not on all interfaces. The app is not exposed to the network; it is exposed to a reverse proxy on the same host, which terminates TLS and does auth. This is the single most effective networking decision in the whole setup, and it costs one line. If you are on a single box with one public IP and no reverse proxy yet, put one in front (Caddy or Traefik both fit), and keep every app port on loopback.
Secrets are where this step usually goes wrong, so let me be blunt about both halves of it. The wiring half: POSTGRES_PASSWORD_FILE only points at a path, and Compose infers nothing from it. The *_FILE convention needs the service-level secrets: entry that grants the container access, plus the top-level secrets: block that names the host file to mount at /run/secrets/db_password. Miss either half and the Postgres container dies on first boot with "/run/secrets/db_password": No such file or directory. The storage half: the .env file and the secret file are plaintext, and the playbook must write both with mode 0600 and keep them out of version control. For anything more sensitive, the password lives in an Ansible Vault-encrypted variable and the task templates the secret file from it, which keeps the value in the playbook's encrypted layer instead of on disk in clear text. Docker secrets buy you a credential that never appears in docker inspect output, but the floor is 0600 and no git.
Running it, and running it again
The moment of truth is the first run, which is the only one that needs a password. After that, key auth is in place and the playbook is how you bring a box to baseline, how you add a second server, and how you re-provision after a disaster.
$ ansible-playbook -i inventory/hosts.ini playbooks/setup.yml \
-u root --ask-pass --ask-become-pass
# after SSH keys are in place
$ ansible-playbook -i inventory/hosts.ini playbooks/setup.yml
# check mode: show what would change, change nothing
$ ansible-playbook -i inventory/hosts.ini playbooks/setup.yml --check
# if you keep secrets in Vault
$ ansible-playbook -i inventory/hosts.ini playbooks/setup.yml \
--vault-id secrets@promptTwo habits keep this safe over time. Run --check before every real run, because that is the diff review for infrastructure. And keep the playbook itself in git, because the history of the playbook is the history of your server's configuration, which is the thing you will need to answer “why is this box like this?” six months from now.
What I deliberately left out
A secure baseline is not a fortress, and being honest about what is not here is part of the trade-off discussion.
- fail2ban or crowdsec. With password auth disabled, the SSH brute-force surface is largely gone, and these tools add a moving part that occasionally false-positives on legitimate users. They are a reasonable extra layer on a box that must accept more traffic; I leave them out of the baseline.
- Kernel hardening (sysctl, AppArmor, SELinux). There are excellent hardened profiles (the konstruktoid role is a good starting point), but they interact with Docker in ways that need real testing. Enable them when the threat model justifies it, not on day one.
- Container image updates. The playbook installs Docker, but updating the images inside
compose.yamlis a separate discipline. I pin tags, watch releases, and update deliberately; automatic image updates are a policy decision with its own trade-offs, and my Docker vs Podman post covers the Watchtower vs manual-update argument. - Monitoring and alerting. The backups need to be verified, the healthchecks need eyes. A monitoring stack is the natural next role after this one, and it deserves its own post.
Official sources
- Debian 13 Trixie release info: https://www.debian.org/releases/trixie/
- Debian wiki on periodic updates and unattended-upgrades: https://wiki.debian.org/PeriodicUpdates
- Docker Engine install docs for Debian: https://docs.docker.com/engine/install/debian/
- restic documentation: https://restic.readthedocs.io/
- Ansible apt_repository and user module docs: https://docs.ansible.com/
The playbook above is a starting point, not a final answer. What is in your baseline role, and which trade-offs did you land on for automatic reboots and container image updates? Drop your setup in the comments.
Until next time, keep your systems thoughtful.

No comments yet