Automating an Ubuntu Web Server with Ansible Roles
Turn a manually configured Ubuntu server into a repeatable Ansible deployment with roles for packages, Nginx, application files, and SSH access.
Manually configuring one Linux server is manageable. Repeating the same commands reliably—or returning months later and remembering every change—is where configuration management earns its place.
My Ansible Server Setup project configures an Ubuntu Server VM from a Linux Mint controller. The target is deliberately simple: an Nginx web server with a static site, basic utilities, Fail2ban, and SSH key access. The project focuses on the structure that makes the setup repeatable.
The control path
The topology has two machines:
Linux Mint host (Ansible controller)
|
| SSH
v
Ubuntu Server VM (managed node)
Ansible runs on the controller. It reads the inventory, connects to the managed node over SSH, becomes root where required, and converges the server toward the declared state.
The inventory gives the target a readable name:
[webservers]
bantu ansible_user=sam
The name bantu must resolve through DNS or the controller’s SSH configuration. An IP address or ansible_host value can be used instead when name resolution is unavailable.
Keeping the playbook small with roles
The top-level setup.yml describes intent rather than implementation details:
---
- name: Configure Ubuntu server
hosts: webservers
become: true
roles:
- role: base
tags: [base]
- role: nginx
tags: [nginx]
- role: app
tags: [app]
- role: ssh
tags: [ssh]
Each role owns a distinct concern.
Base role
The base role refreshes the apt cache, upgrades installed packages, installs common utilities, and enables Fail2ban. The tasks use Ansible modules such as apt and service instead of shell commands, giving Ansible enough information to determine whether a change is needed.
Nginx role
The Nginx role installs the package, deploys a Jinja configuration template, ensures the default site is enabled, and starts the service at boot.
The template serves /var/www/html and uses a strict static-site fallback:
location / {
try_files $uri $uri/ =404;
}
Configuration changes notify a handler rather than reloading Nginx unconditionally:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
That distinction matters. A handler runs only when a notifying task changes something, keeping repeated playbook runs quieter and safer.
Application role
The app role ensures the web root exists, copies a packaged website archive to the server, extracts it, and recursively assigns ownership to www-data. Packaging the site into an archive keeps the transfer simple, although synchronize, a build artifact, or a dedicated release directory can be more efficient for larger sites.
SSH role
The SSH role reads a public key from the controller and adds it to the managed user’s authorized_keys through the authorized_key module:
ssh_public_key: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/ubuntu_server.pub') }}"
Only the .pub file belongs in this workflow. Private keys must remain outside the repository and readable only by their owner.
Running the full configuration
First verify that the controller can reach the managed node:
ansible -i inventory.ini webservers -m ping
Then apply every role:
ansible-playbook -i inventory.ini setup.yml
Tags make focused runs possible during development:
ansible-playbook -i inventory.ini setup.yml --tags base
ansible-playbook -i inventory.ini setup.yml --tags nginx
ansible-playbook -i inventory.ini setup.yml --tags app
ansible-playbook -i inventory.ini setup.yml --tags ssh
Run the complete playbook a second time after a successful deployment. Ideally, it should report few or no changes. That second run is a practical idempotency check.
Lessons from the design
This project highlights why configuration should be declarative:
- The inventory separates machines from configuration logic.
- Roles create clear ownership boundaries.
- Modules describe desired state better than raw shell commands.
- Handlers avoid unnecessary service reloads.
- Tags shorten the feedback loop when working on one layer.
- The playbook itself becomes executable documentation.
The next production-oriented steps would be adding Ansible Vault for secrets, validating Nginx configuration before reload, introducing a non-root deployment user, pinning supported operating-system versions, and testing roles in CI.
The source, role definitions, template, and example site are available in the ansible-playbook repository.