Nginx ansible role

2024-10-28 - The ansible role I use to manage my nginx web servers
Tags: ansible nginx

Introduction

Before succumbing to nixos, I had been using an ansible role to manage my nginx web servers. Now that I am in need of it again I refined it a bit: here is the result.

The role

Vars

The role has OS specific vars in files named after the operating system. For example in vars/Debian.yaml I have:

---
nginx:
  etc_dir: '/etc/nginx'
  pid_file: '/run/nginx.pid'
  www_user: 'www-data'

While in vars/FreeBSD.yaml I have:

---
nginx:
  etc_dir: '/usr/local/etc/nginx'
  pid_file: '/var/run/nginx.pid'
  www_user: 'www'

Tasks

The main tasks file setups nginx and the global configuration common to all virtual hosts:

---
- include_vars: '{{ ansible_distribution }}.yaml'

- name: 'Install nginx'
  package:
    name:
      - 'nginx'

- name: 'Make nginx vhost directory'
  file:
    path: '{{ nginx.etc_dir }}/vhost.d'
    mode: '0755'
    owner: 'root'
    state: 'directory'

- name: 'Deploy nginx configuration files'
  copy:
    src: '{{ item }}'
    dest: '{{ nginx.etc_dir }}/{{ item }}'
  notify: 'reload nginx'
  loop:
    - 'headers_base.conf'
    - 'headers_secure.conf'
    - 'headers_static.conf'
    - 'headers_unsafe_inline_csp.conf'

- name: 'Deploy nginx configuration template'
  template:
    src: 'nginx.conf'
    dest: '{{ nginx.etc_dir }}/'
  notify: 'reload nginx'

- name: 'Deploy nginx certificates'
  copy:
    src: '{{ item }}'
    dest: '{{ nginx.etc_dir }}/'
  notify: 'reload nginx'
  loop:
    - 'adyxax.org.fullchain'
    - 'adyxax.org.key'
    - 'dh4096.pem'

- name: 'Start nginx and activate it on boot'
  service:
    name: 'nginx'
    enabled: true
    state: 'started'

I have a vhost.yaml task file which currently simply deploys a file and reload nginx:

- name: 'Deploy {{ vhost.name }} vhost {{ vhost.path }}'
  template:
    src: '{{ vhost.path }}'
    dest: '{{ nginx.etc_dir }}/vhost.d/{{ vhost.name }}.conf'
  notify: 'reload nginx'

Handlers

There is a single main.yaml handler:

---
- name: 'reload nginx'
  service:
    name: 'nginx'
    state: 'reloaded'

Files

I deploy four configuration files in this role. These are all variants of the same theme and their purpose is just to prevent duplicating statements in the virtual hosts configuration files.

headers_base.conf:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

add_header X-Frame-Options deny;
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy strict-origin;
add_header Cache-Control no-transform;
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
# 6 months HSTS pinning
add_header Strict-Transport-Security max-age=16000000;

headers_secure.conf:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

include headers_base.conf;
add_header Content-Security-Policy "script-src 'self'";

headers_static.conf:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

include headers_secure.conf;
# Infinite caching
add_header Cache-Control "public, max-age=31536000, immutable";

headers_unsafe_inline_csp.conf:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

include headers_base.conf;
add_header Content-Security-Policy "script-src 'self' 'unsafe-inline'";

Templates

I have a single template for nginx.conf:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

user {{ nginx.www_user }};
worker_processes  auto;
pid {{ nginx.pid_file }};
error_log /var/log/nginx/error.log;

events {
    worker_connections  1024;
}

http {
    include              mime.types;
    types_hash_max_size  4096;
    sendfile             on;
    tcp_nopush           on;
    tcp_nodelay          on;
    keepalive_timeout    65;

    ssl_protocols  TLSv1.2 TLSv1.3;
    ssl_ciphers    ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers on;

    gzip             on;
    gzip_static      on;
    gzip_vary        on;
    gzip_comp_level  5;
    gzip_min_length  256;
    gzip_proxied     expired  no-cache  no-store  private  auth;
    gzip_types  application/atom+xml  application/geo+json  application/javascript  application/json  application/ld+json  application/manifest+json  application/rdf+xml  application/vnd.ms-fontobject  application/wasm  application/x-rss+xml  application/x-web-app-manifest+json  application/xhtml+xml  application/xliff+xml  application/xml  font/collection  font/otf  font/ttf  image/bmp  image/svg+xml  image/vnd.microsoft.icon  text/cache-manifest  text/calendar  text/css  text/csv  text/javascript  text/markdown  text/plain  text/vcard  text/vnd.rim.location.xloc  text/vtt  text/x-component  text/xml;

    proxy_redirect         off;
    proxy_connect_timeout  60s;
    proxy_send_timeout     60s;
    proxy_read_timeout     60s;
    proxy_http_version     1.1;
    proxy_set_header       "Connection"        "";
    proxy_set_header       Host                $host;
    proxy_set_header       X-Real-IP           $remote_addr;
    proxy_set_header       X-Forwarded-For     $proxy_add_x_forwarded_for;
    proxy_set_header       X-Forwarded-Proto   $scheme;
    proxy_set_header       X-Forwarded-Host    $host;
    proxy_set_header       X-Forwarded-Server  $host;

    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    client_max_body_size  40M;
    server_tokens         off;
    default_type          application/octet-stream;
    access_log            /var/log/nginx/access.log;

    fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
    fastcgi_param  QUERY_STRING     $query_string;
    fastcgi_param  REQUEST_METHOD   $request_method;
    fastcgi_param  CONTENT_TYPE     $content_type;
    fastcgi_param  CONTENT_LENGTH   $content_length;

    fastcgi_param  SCRIPT_NAME      $fastcgi_script_name;
    fastcgi_param  REQUEST_URI      $request_uri;
    fastcgi_param  DOCUMENT_URI     $document_uri;
    fastcgi_param  DOCUMENT_ROOT    $document_root;
    fastcgi_param  SERVER_PROTOCOL  $server_protocol;
    fastcgi_param  REQUEST_SCHEME   $scheme;
    fastcgi_param  HTTPS            $https                 if_not_empty;

    fastcgi_param  GATEWAY_INTERFACE  CGI/1.1;
    fastcgi_param  SERVER_SOFTWARE    nginx/$nginx_version;

    fastcgi_param  REMOTE_ADDR  $remote_addr;
    fastcgi_param  REMOTE_PORT  $remote_port;
    fastcgi_param  REMOTE_USER  $remote_user;
    fastcgi_param  SERVER_ADDR  $server_addr;
    fastcgi_param  SERVER_PORT  $server_port;
    fastcgi_param  SERVER_NAME  $server_name;

    # PHP only, required if PHP was built with --enable-force-cgi-redirect
    fastcgi_param  REDIRECT_STATUS  200;

    uwsgi_param  QUERY_STRING    $query_string;
    uwsgi_param  REQUEST_METHOD  $request_method;
    uwsgi_param  CONTENT_TYPE    $content_type;
    uwsgi_param  CONTENT_LENGTH  $content_length;

    uwsgi_param  REQUEST_URI      $request_uri;
    uwsgi_param  PATH_INFO        $document_uri;
    uwsgi_param  DOCUMENT_ROOT    $document_root;
    uwsgi_param  SERVER_PROTOCOL  $server_protocol;
    uwsgi_param  REQUEST_SCHEME   $scheme;
    uwsgi_param  HTTPS            $https             if_not_empty;

    uwsgi_param  REMOTE_ADDR  $remote_addr;
    uwsgi_param  REMOTE_PORT  $remote_port;
    uwsgi_param  SERVER_PORT  $server_port;
    uwsgi_param  SERVER_NAME  $server_name;

    ssl_dhparam          dh4096.pem;
    ssl_session_cache    shared:SSL:2m;
    ssl_session_timeout  1h;
    ssl_session_tickets  off;

    server {
        listen                   80       default_server;
        listen                   [::]:80  default_server;
        server_name              _;
        access_log               off;
        server_name_in_redirect  off;
        return                   444;
    }

    server {
        listen                   443       ssl;
        listen                   [::]:443  ssl;
        server_name              _;
        access_log               off;
        server_name_in_redirect  off;
        return                   444;
        ssl_certificate      adyxax.org.fullchain;
        ssl_certificate_key  adyxax.org.key;
    }

    include vhost.d/*.conf;
}

Usage example

I do not call the role from a playbook, I prefer running the setup from an application’s role that relies on nginx using a meta/main.yaml containing something like:

---
dependencies:
  - role: 'borg'
  - role: 'nginx'
  - role: 'postgresql'

Then from a tasks file:

- include_role:
    name: 'nginx'
    tasks_from: 'vhost'
  vars:
    vhost:
      name: 'www'
      path: 'roles/www.adyxax.org/files/nginx-vhost.conf'

I did not find an elegant way to pass a file path local to one role to another. Because of that, here I just specify the full vhost file path complete with the roles/ prefix.

Conclusion

I you have an elegant idea for passing the local file path from one role to another do not hesitate to ping me!