Ansible playbooks for comprehensive server management on Debian/Ubuntu based Linux systems.
This repository provides a collection of Ansible playbooks and roles designed to automate common server management tasks, user administration, and system configuration. Whether you're managing a single server or a fleet of machines, these playbooks help ensure consistent, repeatable deployments. It is directly based on https://github.com/konstruktoid 's work. Please check out the upstream repos and, if you can, support their work.
- User Management: Automated user account creation with SSH key management and sudo configuration
- Two-Factor Authentication: TOTP-based 2FA (Aegis, Google Authenticator, Authy, Bitwarden, etc...) for SSH login
- Docker Installation: Rootless Docker setup for enhanced security
- Multi-Environment Support: Organized inventory structure for different environments
- Testing Framework: Vagrant-based testing for playbook validation
- Security-First Approach: Best practices for secure server management
- Debian: Trixie (13) and newer
- Ubuntu: Resolute Raccoon (26.04 LTS) and newer
- Architecture: x86_64 and aarch64 (arm64 - only Ubuntu 26.04 tested)
You run these playbooks from a control machine (your laptop) against remote
servers over SSH. The only hard requirements are git, Python 3, and Ansible
(installed in the next section). VirtualBox + Vagrant are only needed if you
want to run the local VM test suite — skip them if you're provisioning real hosts.
sudo apt update
sudo apt install git python3 python3-venvbrew install git pythonNote: On modern macOS (Homebrew Python) and Debian 12+/13, Python is "externally managed" (PEP 668), so a global
pip installis blocked. The Installation section below uses a virtual environment, which is the supported path on every platform. Ansible itself is installed there — not via the OS package manager.
Only needed for the Testing section.
sudo apt install virtualbox vagrantbrew install --cask virtualbox
brew install hashicorp/tap/hashicorp-vagrant-
Clone the repository:
git clone <repository-url> cd linux-server-management
-
Create a virtual environment and install Python dependencies (installs Ansible, ansible-lint, passlib, jmespath):
python3 -m venv .venv source .venv/bin/activate # run this in every new shell before using ansible pip install -r requirements.txt
-
Install Ansible dependencies (collections; the playbook also pulls the hardening role automatically at runtime):
ansible-galaxy install -r requirements.yml
The
.venvmust be active (source .venv/bin/activate) whenever you runansible,ansible-playbook, oransible-galaxy. On macOS usepip/python3from inside the venv (a barepipmay not exist system-wide).
Main system setup playbook that includes:
- User management and SSH key configuration
- CIS-based system hardening via https://github.com/konstruktoid/ansible-role-hardening and cusomizable
- Essential package installation
Installs Docker in rootless mode for enhanced security via https://github.com/konstruktoid/ansible-role-docker-rootless:
- Configures rootless Docker daemon
- Sets up user namespaces
- Enables Docker service for non-root users
Manages user accounts with the following features:
- Creates user accounts with individual groups
- Configures SSH public key authentication
- Sets up sudo permissions for admin users
- Allows limited sudo commands for non-admin users
- Forces password change on first login
- Two-Factor Authentication (2FA): Optional TOTP-based 2FA enforcement
Required Variables:
SSH_USERLIST: List of users to create (used by users_add role)GENERALINITIALPASSWORD: Default initial password (use Ansible Vault)ENABLE_2FA: Enable/disable 2FA globally (true/false)AUDITD_ACTION_MAIL_ACCT: Email address for audit system alertsMANAGE_UFW: Enable/disable UFW firewall management (true/false)UFW_OUTGOING_TRAFFIC: List of allowed outbound traffic rulesAUTO_UPDATES_OPTIONS: Automatic-updates policy; itsrebootkey (bool) is required by pre-flightSSHD_ADMIN_NET: List of networks allowed for SSH admin access
Example Configuration:
# inventories/your-environment/group_vars/all/vars.yml
SSH_USERLIST:
- username: "admin_user"
admin: true
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."
ENABLE_2FA: true # Enforce TOTP-based 2FA for SSH login
AUDITD_ACTION_MAIL_ACCT: "security@example.com"
MANAGE_UFW: true
UFW_OUTGOING_TRAFFIC:
- "80/tcp" # HTTP
- "443/tcp" # HTTPS
- "53" # DNS
AUTO_UPDATES_OPTIONS:
enabled: true
only_security: true
reboot: false # required by pre-flight (must be a boolean)
reboot_from_time: "02:00"
reboot_time_margin_mins: 20
SSHD_ADMIN_NET:
- "192.168.1.0/24"
- "10.0.0.0/8"
# Disable client-side DNSSEC if your network breaks resolution after hardening
# (see Troubleshooting). Validation still happens at the upstream resolvers.
# DNSSEC: false
# Optional security overrides (defaults shown)
# SSHD_MAX_AUTH_TRIES: 3 # CIS compliant (default)
# SSHD_LOGIN_GRACE_TIME: 60 # Extended for password complexity
# SESSION_TIMEOUT: 900 # 15 min for operational efficiencyCreate your own inventory under inventories/<your-env>/ — an inventory file
plus host_vars/ (and optionally group_vars/).
Inventories are gitignored by design (see the top of
.gitignore): they contain environment-specific IPs, usernames and secrets, so the repository ships no inventories — a fresh clone has none. Maintain yours locally or in a private repo. Usedocs/inventory.mdand the example configuration above (theusers_addvariables block) as your template.
Define your host/group variables under inventories/<your-env>/host_vars/ and
inventories/<your-env>/group_vars/. See docs/inventory.md for a full walkthrough.
setup-playbook.yml runs a pre-flight check and fails immediately unless all
of these are defined for the target host: SSH_USERLIST,
AUDITD_ACTION_MAIL_ACCT, MANAGE_UFW, UFW_OUTGOING_TRAFFIC,
AUTO_UPDATES_OPTIONS.reboot, SSHD_ADMIN_NET, and ENABLE_2FA (required even
when false). Define every one of them for each host you target.
A freshly created server usually only has the root account — and this playbook
is what creates your unprivileged users. So the first run must connect as
root. The same run also hardens the box and, when DISABLE_ROOT_ACCOUNT: true,
disables root SSH and password authentication at the end — so this is a
one-time bootstrap. Order matters.
source .venv/bin/activate # if not already active
# 1. Accept the new host's SSH fingerprint (host key checking is enforced).
# A brand-new host has never been seen, so connect once manually first:
ssh -o StrictHostKeyChecking=accept-new root@<server-ip> true
# 2. (Optional) Smoke-test connectivity through Ansible:
ansible -i inventories/<your-env>/inventory <your-host> -u root -m ping
# 3. First run — connect as root. No -K needed: root has no sudo password.
ansible-playbook -i inventories/<your-env>/inventory \
-l <your-host> \
-u root \
setup-playbook.ymlThis creates the users in SSH_USERLIST, applies CIS hardening, and locks down
SSH. New users are forced to change their password on first login.
If
rootonly accepts a password (not your key), copy your key up first withssh-copy-id root@<server-ip>, or add--ask-pass(requiressshpass).
After the bootstrap, root login is gone. Connect as one of the admin users you
just created, and add -K so Ansible can prompt for the sudo password:
ANSIBLE_USER="your_admin_user"
# ssh-add your key first to avoid repeated passphrase prompts.
ansible-playbook -i inventories/<your-env>/inventory \
-l <your-host> \
-u "$ANSIBLE_USER" \
-K \
setup-playbook.ymlTest playbooks on virtual machines before deploying to production:
# Ensure `ansible-playbook` is on your PATH (Vagrant's Ansible provisioner uses the host install).
# One reliable option is a local venv:
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
# Install Ansible dependencies used by the test playbooks
ansible-galaxy install -r requirements.yml
ansible-galaxy install --no-deps -r testing/requirements.yml
# Pick the playbook to run via the Vagrant Ansible provisioner.
# If not set, Vagrant defaults to: testing/test-new-version-hardening.yml
export TEST_PLAYBOOK=testing/e2e-hardened-then-install-docker-rootless.yml
# Other useful options:
# export TEST_PLAYBOOK=testing/test-new-version-hardening.yml
# export TEST_PLAYBOOK=testing/test-new-version-docker-rootless.yml
# Start and provision test VMs (Debian 13, Ubuntu 26.04)
PATH="$PWD/.venv/bin:$PATH" vagrant up# Test specific roles
ansible-playbook -i testing/inventory \
--tags "users" \
setup-playbook.yml- SSH Key Authentication: Password authentication is discouraged; use SSH keys
- Ansible Vault: Store sensitive variables (passwords, keys) using
ansible-vault - Least Privilege: Non-admin users receive minimal sudo permissions
- Initial Password Policy: Users must change passwords on first login
GitHub Actions runs a few gates:
- Lint: ansible-lint/syntax checks against repository playbooks.
- Integration (Vagrant): provisions one or more VMs and runs the playbooks end-to-end (VirtualBox-based).
- Integration (Vagrant, ARM64): same as above, but runs on a self-hosted
macOSARM64runner (VirtualBox on Apple Silicon). - Deps: new role version testing: for dependency update PRs, runs Vagrant with a role-focused test playbook before auto-updating the pinned SHA in the top-level playbook.
The Docker rootless integration test is intentionally end-to-end: it assumes the baseline hardening was applied first.
-
Rootless Docker cgroups: if Docker reports
Cgroup Driver: nonein rootless mode, resource limits (--memory,--cpus,--pids-limit) are not enforced. This repo currently writes/home/dockeruser/.config/docker/daemon.jsonto forcenative.cgroupdriver=cgroupfsas a workaround in some environments, which can cause cgroups to be effectively disabled in others. To verify: rundocker info | grep -i cgroupas the rootless Docker user. If you need cgroup enforcement in rootless mode and your system supports it, remove that override and restart the user service (systemctl --user restart docker). For example arm64 Ubuntu 26.04. -
systemd-resolved DNSSEC: the hardening role defaults to
DNSSEC=allow-downgrade. On networks where upstream answers fail validation (no-signature, common with CDN/CNAME-backed mirrors such asdeb.debian.org), this breaks DNS andaptpartway through a run. Override per host/group withDNSSEC: false— see the Troubleshooting entry above.
# Create encrypted variable file
ansible-vault create inventories/your-environment/group_vars/all/vault.yml
# Edit encrypted file
ansible-vault edit inventories/your-environment/group_vars/all/vault.yml
# Run playbook with vault
ansible-playbook -i inventory playbook.yml --ask-vault-passSSH Connection Failed
- Ensure you've connected to target hosts at least once to accept SSH fingerprints
- Verify SSH key is loaded:
ssh-add -l - Test manual SSH connection:
ssh user@target-host
Permission Denied (sudo)
- Add
-Kflag to prompt for sudo password - Verify user has sudo permissions on target system
- Check
/etc/sudoers.d/for user-specific rules
root@host: Permission denied (publickey) after a successful run
- Expected. With
DISABLE_ROOT_ACCOUNT: true, the first run disables root SSH. Reconnect as one of the admin users fromSSH_USERLIST(see Quick Start step 4).
Pre-flight fails: Missing required variables / ENABLE_2FA is defined evaluated to false
- The target host is missing one of the mandatory variables. Define all of them
(see Quick Start step 2);
ENABLE_2FAis required even when set tofalse.
externally-managed-environment when running pip install
- You're installing into the system Python (PEP 668). Use the virtual environment
from the Installation section:
python3 -m venv .venv && source .venv/bin/activate.
DNS / apt breaks midway through hardening (Temporary failure resolving ...)
- The hardening role configures
systemd-resolvedwithDNSSEC=allow-downgrade, which on some networks rejects valid answers (DNSSEC validation failed: no-signature) and breaks all name resolution — failing laterapttasks. - Diagnose on the host:
resolvectl query deb.debian.org(look for a DNSSEC error) andresolvectl status | grep -i dnssec. - Fix: set
DNSSEC: falsein your host/group vars.setup-playbook.ymlexposesDNSSECandDNS_OVER_TLSas overridable variables. Validation is still performed by the upstream resolvers (1.1.1.2/9.9.9.9) and the channel is protected by DNS-over-TLS, so this is a safe, common posture.
Ansible Galaxy Dependencies
Run ansible-galaxy install -r requirements.yml --force to update roles
- Check the troubleshooting section above
- Review Ansible logs for detailed error messages
- Test connectivity:
ansible all -i inventory -m ping - Validate playbook syntax:
ansible-playbook --syntax-check playbook.yml - If you are still stuck, open an issue.
- Fork the repository and create a feature branch
- Follow Ansible best practices: Use
ansible-lintto validate changes - Test thoroughly: Ensure playbooks work on supported systems
- Document changes: Update README and role documentation
- Submit a pull request with clear description of changes
- Use 2-space indentation for YAML files
- Add comments for complex logic
- Use descriptive variable names with role prefixes where possible
- Ensure idempotency for all tasks
- Test with
ansible-lintandyamllint
This project is licensed under the Apache License, Version 2.0. See the LICENSE file for details.
Testing, Automation and Vagrant script portions based on work by Thomas Sjögren (2020-2024) from https://github.com/konstruktoid/ansible-role-hardening.
This project and part of its upstream contributions have received funding from the IMI 2 Joint Undertaking (JU) under grant agreement No. 101034344 and the Horizon Europe R&I program through the EBRAINS2.0 project specifically supported by the Swiss State Secretariat for Education, Research and Innovation (SERI) under contract number 23.00638.
For issues and questions:
- Check existing GitHub issues
- Create a new issue with detailed description
- Include Ansible version, target OS, and error messages