← Back to writing

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.