Setting Up a Bare Debian Server with Ansible: Hardening, Unattended Upgrades, Backups, and Docker

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.
Verified
  • Debian13 (Trixie), point release 13.5
  • ansible-corecurrent stable
  • Docker Enginestable channel via download.docker.com
  • unattended-upgradescurrent Debian package
  • resticcurrent 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
    - docker

Why 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 sshd

The 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: restarted

The 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 journald

A 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.target

The 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: true

Two 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_password

Three 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.

first run against a fresh box
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@prompt

Two 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.yaml is 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