commit 72b96c06a4b54fb51819a8d8a55b4269eb89cc86 Author: patrick Date: Wed Jun 24 17:29:12 2026 -0400 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8216f7 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# nginx (Ansible role) + +Installs and configures nginx for **simple homelab / local-network** setups, where most +sites are "proxy a domain to a backend over HTTPS" and share a single wildcard cert. + +This role exists because most of my nginx configs were nearly identical — same SSL +params, same proxy headers, same redirect-to-HTTPS pattern. The role captures that +shared shape, so each new service is a few lines of YAML instead of a copy-pasted +server block. + +**Not for production.** No Let's Encrypt automation, no per-vhost cert provisioning, +no cleanup for sites removed from inventory, snakeoil cert as default. Override what +you need. + +Tested on Debian (bookworm/trixie) and Ubuntu (jammy/noble). + +--- + +## Quick start + +```yaml +- hosts: nginx + become: true + roles: + - role: nginx + vars: + # Override the default snakeoil cert with your real one + nginx_ssl_certificate: /etc/nginx/ssl/lab.local/fullchain.pem + nginx_ssl_certificate_key: /etc/nginx/ssl/lab.local/privkey.pem + + nginx_sites: + - name: default # keep the catch-all + template_path: site-configs/default.conf.j2 + + - name: vaultwarden # your service + domain_names: + - vault.lab.local + upstream_url: http://192.168.1.10:8080 +``` + +That's it. `vault.lab.local` now serves over HTTPS, redirects plain HTTP → HTTPS, +uses the shared cert, and proxies everything to `192.168.1.10:8080`. + +--- + +## Per-site options (`nginx_sites[*]`) + +| Key | Type | Default | Notes | +|------------------------|----------|----------------------------------|-----------------------------------------------------------------------| +| `name` | string | **required** | Filename in `sites-available/`. | +| `domain_names` | list | **required** * | Values for `server_name`. | +| `upstream_url` | string | **required** * | `proxy_pass` target (e.g. `http://host:port`). | +| `ssl` | bool | `true` | Enable HTTPS listener + cert. | +| `allow_http` | bool | `false` | Also serve content over plain HTTP. By default HTTP → 301 → HTTPS. | +| `websockets` | bool | `false` | Include `websockets.conf` (upgrade headers). | +| `max_upload_size` | string | unset | `client_max_body_size` value, e.g. `"50m"`. | +| `extra_parameters` | string | unset | Freeform nginx directives injected into the server block. | +| `https_port` | int | `443` | Override HTTPS listen port. | +| `http_port` | int | `80` | Override HTTP listen port. | +| `ssl_certificate` | path | `nginx_ssl_certificate` | Per-site cert override. | +| `ssl_certificate_key` | path | `nginx_ssl_certificate_key` | Per-site key override. | +| `enabled` | bool | `nginx_site_enabled_by_default` | Create the `sites-enabled/` symlink. Setting `false` removes it. | +| `template_path` | string | `nginx_site_config_template` | Use a custom template (path relative to `templates/`). | +| `conf_file` | path | unset | Use a static file instead of the template (path relative to playbook).| + +\* Required when using the built-in template. If you supply `conf_file` or a custom +`template_path`, only `name` is required. + +--- + +## Role variables + +| Variable | Default | Purpose | +|---------------------------------------|-----------------------------------------------|----------------------------------------------------------------------| +| `nginx_ssl_certificate` | `/etc/ssl/certs/ssl-cert-snakeoil.pem` | Default cert path for all sites. | +| `nginx_ssl_certificate_key` | `/etc/ssl/private/ssl-cert-snakeoil.key` | Default key path. | +| `nginx_resolver` | `127.0.0.53 valid=300s` | Global DNS resolver (used by OCSP stapling). Override for LAN DNS. | +| `nginx_resolver_timeout` | `5s` | | +| `nginx_site_enabled_by_default` | `true` | Whether sites without an explicit `enabled` get symlinked. | +| `nginx_delete_default_site_config` | `false` | Delete Debian's `sites-available/default` (only matters if you remove the `default` entry from `nginx_sites`). | +| `nginx_site_config_template` | `nginx-site.conf.j2` | Default template used when a site has no `template_path`/`conf_file`.| +| `nginx_sites` | one entry (the catch-all `default` site) | Your list of sites. | +| `nginx_snippets` | `[proxy-headers, ssl-params, websockets, fastcgi-php]` | Static snippets dropped into `/etc/nginx/snippets/`. | +| `nginx_conf_d_templates` | `[resolver.conf]` | Templated files rendered into `/etc/nginx/conf.d/`. | + +Path variables (`nginx_site_config_path`, `nginx_site_enabled_path`, +`nginx_snippets_path`, `nginx_conf_d_path`) exist for completeness and default to +Debian's canonical locations. You usually shouldn't touch them. + +--- + +## The default catch-all site + +The shipped default entry (`name: default`) renders +`templates/site-configs/default.conf.j2`, which: + +- Listens on `:80` with `default_server`, 301-redirects any unknown `Host:` to HTTPS. +- Listens on `:443 ssl http2` with `default_server`, returns `404`. + +It exists so unknown-host traffic doesn't leak one of your real sites' certs/content +via SNI scans. Keep it if you have more than one site; remove it (and set +`nginx_delete_default_site_config: true`) if you have exactly one. + +--- + +## Custom static configs + +For sites that don't fit the template — e.g. a static file server, a non-proxy +site, an upstream block — write the full nginx config yourself and point at it: + +```yaml +- name: media + conf_file: site-configs/media.conf # path relative to your playbook +``` + +The role copies it verbatim into `sites-available/`. + +--- + +## Caveats + +- **Snakeoil default cert.** The role works out of the box but presents an untrusted + self-signed cert. Set `nginx_ssl_certificate(_key)` to your real cert before + serving anything real. +- **One shared cert by default.** A single wildcard is the assumed homelab pattern. + Per-site override via `ssl_certificate` / `ssl_certificate_key` works for the + exceptions. +- **No removal cleanup.** If you delete a site from `nginx_sites`, the existing + `sites-available/` and `sites-enabled/` are not removed. Clean up + manually or set `enabled: false` and let the symlink task remove the link. +- **Debian/Ubuntu only.** Paths assume the Debian-style `sites-available/` + + `sites-enabled/` split. Won't work on Alpine, RHEL, etc. without surgery. +- **No Let's Encrypt / ACME.** Bring your own certs. +- **Resolver default assumes systemd-resolved.** Override `nginx_resolver` if you + use a different local resolver (e.g. `"dns.home valid=300s"`). diff --git a/defaults/main.yaml b/defaults/main.yaml new file mode 100755 index 0000000..383f6ec --- /dev/null +++ b/defaults/main.yaml @@ -0,0 +1,38 @@ +--- +nginx_config_path: /etc/nginx + +nginx_site_config_template: "nginx-site.conf.j2" +nginx_site_config_path: /etc/nginx/sites-available +nginx_site_enabled_path: /etc/nginx/sites-enabled +nginx_site_enabled_by_default: true +nginx_snippets_path: /etc/nginx/snippets +nginx_conf_d_path: /etc/nginx/conf.d +nginx_delete_default_site_config: false +nginx_site_config_extra_options: "" + +# Default SSL cert used by all sites unless overridden per-site +# via item.ssl_certificate / item.ssl_certificate_key. Points at the +# Debian snakeoil cert (shipped by the ssl-cert package, an nginx dep) +# so the role works out of the box; override for real deployments. +nginx_ssl_certificate: /etc/ssl/certs/ssl-cert-snakeoil.pem +nginx_ssl_certificate_key: /etc/ssl/private/ssl-cert-snakeoil.key + +# DNS resolver used by ssl_stapling and any proxy_pass with hostnames. +# Defaults to systemd-resolved's stub (present on modern Debian/Ubuntu). +# Override with your local resolver (e.g. "192.168.1.5 valid=300s"). +nginx_resolver: "127.0.0.53 valid=300s" +nginx_resolver_timeout: "5s" + +nginx_snippets: + - proxy-headers.conf + - ssl-params.conf + - websockets.conf + - fastcgi-php.conf + +nginx_conf_d_templates: + - resolver.conf + +nginx_sites: + + - name: default + template_path: "site-configs/default.conf.j2" diff --git a/files/nginx.conf b/files/nginx.conf new file mode 100755 index 0000000..0d588c6 --- /dev/null +++ b/files/nginx.conf @@ -0,0 +1,58 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +error_log /var/log/nginx/error.log; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + types_hash_max_size 2048; + # server_tokens off; + + server_names_hash_max_size 768; + server_names_hash_bucket_size 96; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + + ## + # Gzip Settings + ## + + gzip on; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} + + diff --git a/files/snippets/fastcgi-php.conf b/files/snippets/fastcgi-php.conf new file mode 100755 index 0000000..467a9e7 --- /dev/null +++ b/files/snippets/fastcgi-php.conf @@ -0,0 +1,13 @@ +# regex to split $uri to $fastcgi_script_name and $fastcgi_path +fastcgi_split_path_info ^(.+?\.php)(/.*)$; + +# Check that the PHP script exists before passing it +try_files $fastcgi_script_name =404; + +# Bypass the fact that try_files resets $fastcgi_path_info +# see: http://trac.nginx.org/nginx/ticket/321 +set $path_info $fastcgi_path_info; +fastcgi_param PATH_INFO $path_info; + +fastcgi_index index.php; +include fastcgi.conf; diff --git a/files/snippets/proxy-headers.conf b/files/snippets/proxy-headers.conf new file mode 100755 index 0000000..496a115 --- /dev/null +++ b/files/snippets/proxy-headers.conf @@ -0,0 +1,5 @@ +# /etc/nginx/snippets/proxy-headers.conf +proxy_set_header Host $http_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; \ No newline at end of file diff --git a/files/snippets/ssl-params.conf b/files/snippets/ssl-params.conf new file mode 100644 index 0000000..e68572b --- /dev/null +++ b/files/snippets/ssl-params.conf @@ -0,0 +1,10 @@ +# /etc/nginx/snippets/ssl-params.conf +# Shared TLS parameters. Cert paths live with the site, not here. +ssl_protocols TLSv1.2 TLSv1.3; +ssl_prefer_server_ciphers on; +ssl_ciphers EECDH+AESGCM:EDH+AESGCM; +ssl_session_cache shared:SSL:10m; +ssl_session_timeout 10m; + +ssl_stapling on; +ssl_stapling_verify on; diff --git a/files/snippets/websockets.conf b/files/snippets/websockets.conf new file mode 100755 index 0000000..e8afad6 --- /dev/null +++ b/files/snippets/websockets.conf @@ -0,0 +1,6 @@ +# /etc/nginx/snippets/websockets.conf +# enable websockets: http://nginx.org/en/docs/http/websocket.html +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection "upgrade"; +proxy_redirect off; \ No newline at end of file diff --git a/handlers/main.yaml b/handlers/main.yaml new file mode 100755 index 0000000..efeb62a --- /dev/null +++ b/handlers/main.yaml @@ -0,0 +1,11 @@ +--- +- name: Validate nginx configuration + ansible.builtin.command: nginx -t + changed_when: false + become: true + +- name: Reload nginx + ansible.builtin.service: + name: nginx + state: reloaded + become: true diff --git a/meta/main.yaml b/meta/main.yaml new file mode 100644 index 0000000..6b41c8f --- /dev/null +++ b/meta/main.yaml @@ -0,0 +1,24 @@ +--- +galaxy_info: + role_name: nginx + author: Patrick Jaroszewski + description: Installs and configures nginx with templated site configs + license: MIT + min_ansible_version: "2.14" + platforms: + - name: Debian + versions: + - bullseye + - bookworm + - trixie + - name: Ubuntu + versions: + - jammy + - noble + galaxy_tags: + - nginx + - web + - proxy + - tls + +dependencies: [] diff --git a/tasks/configure_nginx.yaml b/tasks/configure_nginx.yaml new file mode 100755 index 0000000..9ca4216 --- /dev/null +++ b/tasks/configure_nginx.yaml @@ -0,0 +1,76 @@ +--- +- name: Delete default nginx site configuration + ansible.builtin.file: + path: "{{ nginx_site_config_path }}/default" + state: absent + when: nginx_delete_default_site_config + notify: + - Validate nginx configuration + - Reload nginx + +- name: Add all the template site configurations + ansible.builtin.template: + src: "{{ item.template_path | default(nginx_site_config_template) }}" + dest: "{{ nginx_site_config_path }}/{{ item.name }}" + owner: root + mode: "0644" + when: item.conf_file is undefined + loop: "{{ nginx_sites }}" + notify: + - Validate nginx configuration + - Reload nginx + +- name: Add all site configs using custom files + ansible.builtin.copy: + src: "{{ item.conf_file }}" + dest: "{{ nginx_site_config_path }}/{{ item.name }}" + owner: root + mode: "0644" + when: item.conf_file is defined + loop: "{{ nginx_sites }}" + notify: + - Validate nginx configuration + - Reload nginx + +- name: Manage symlinks for sites + ansible.builtin.file: + src: "{{ nginx_site_config_path }}/{{ item.name }}" + dest: "{{ nginx_site_enabled_path }}/{{ item.name }}" + state: "{{ (item.enabled | default(nginx_site_enabled_by_default)) | ternary('link', 'absent') }}" + loop: "{{ nginx_sites }}" + notify: + - Validate nginx configuration + - Reload nginx + +- name: Copy over configuration snippets + ansible.builtin.copy: + src: "snippets/{{ item }}" + dest: "{{ nginx_snippets_path }}/{{ item }}" + owner: root + mode: "0644" + loop: "{{ nginx_snippets }}" + notify: + - Validate nginx configuration + - Reload nginx + +- name: Render templated conf.d files + ansible.builtin.template: + src: "conf.d/{{ item }}.j2" + dest: "{{ nginx_conf_d_path }}/{{ item }}" + owner: root + mode: "0644" + loop: "{{ nginx_conf_d_templates }}" + notify: + - Validate nginx configuration + - Reload nginx + +- name: Add the main nginx configuration + ansible.builtin.copy: + src: nginx.conf + dest: /etc/nginx/nginx.conf + owner: root + mode: "0644" + validate: 'nginx -t -c %s' + notify: + - Validate nginx configuration + - Reload nginx diff --git a/tasks/install_nginx.yaml b/tasks/install_nginx.yaml new file mode 100755 index 0000000..f064f03 --- /dev/null +++ b/tasks/install_nginx.yaml @@ -0,0 +1,7 @@ +--- +- name: Install NGINX + ansible.builtin.apt: + name: nginx + state: present + update_cache: true + cache_valid_time: 3600 diff --git a/tasks/main.yaml b/tasks/main.yaml new file mode 100755 index 0000000..01548b2 --- /dev/null +++ b/tasks/main.yaml @@ -0,0 +1,3 @@ +--- +- ansible.builtin.include_tasks: install_nginx.yaml +- ansible.builtin.include_tasks: configure_nginx.yaml diff --git a/templates/conf.d/resolver.conf.j2 b/templates/conf.d/resolver.conf.j2 new file mode 100644 index 0000000..9f8c973 --- /dev/null +++ b/templates/conf.d/resolver.conf.j2 @@ -0,0 +1,4 @@ +# /etc/nginx/conf.d/resolver.conf +# Global DNS resolver for runtime lookups (OCSP stapling, runtime proxy_pass). +resolver {{ nginx_resolver }}; +resolver_timeout {{ nginx_resolver_timeout }}; diff --git a/templates/nginx-site.conf.j2 b/templates/nginx-site.conf.j2 new file mode 100755 index 0000000..330efd8 --- /dev/null +++ b/templates/nginx-site.conf.j2 @@ -0,0 +1,60 @@ +{%- if (item.ssl | default(true)) and not (item.allow_http | default(false)) %} +server { + listen {{ item.http_port | default(80) }}; + listen [::]:{{ item.http_port | default(80) }}; + server_name{% for domain_name in item.domain_names %} {{ domain_name }}{% endfor %}; + + return 301 https://$host$request_uri; +} + +{% endif -%} +server { + {%- if item.ssl | default(true) %} + listen {{ item.https_port | default(443) }} ssl http2; + listen [::]:{{ item.https_port | default(443) }} ssl http2; + {%- if item.allow_http | default(false) %} + listen {{ item.http_port | default(80) }}; + listen [::]:{{ item.http_port | default(80) }}; + {%- endif %} + {%- else %} + listen {{ item.http_port | default(80) }}; + listen [::]:{{ item.http_port | default(80) }}; + {%- endif %} + server_name{% for domain_name in item.domain_names %} {{ domain_name }}{% endfor %}; + + # Set proxy headers + include {{ nginx_snippets_path }}/proxy-headers.conf; + + {%- if item.ssl | default(true) %} + + + # SSL certificate and parameters + ssl_certificate {{ item.ssl_certificate | default(nginx_ssl_certificate) }}; + ssl_certificate_key {{ item.ssl_certificate_key | default(nginx_ssl_certificate_key) }}; + include {{ nginx_snippets_path }}/ssl-params.conf; + {%- endif %} + + {%- if item.websockets | default(false) %} + + + # Enable websockets + include {{ nginx_snippets_path }}/websockets.conf; + {%- endif %} + + {%- if item.max_upload_size | default(false) %} + + + # Max upload size + client_max_body_size {{ item.max_upload_size }}; + {%- endif %} + + {%- if item.extra_parameters | default(false) %} + + + {{ item.extra_parameters }} + {%- endif %} + + location / { + proxy_pass {{ item.upstream_url }}; + } +} diff --git a/templates/site-configs/default.conf.j2 b/templates/site-configs/default.conf.j2 new file mode 100644 index 0000000..fa2c793 --- /dev/null +++ b/templates/site-configs/default.conf.j2 @@ -0,0 +1,20 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # Redirect all HTTP requests to HTTPS via a 301 response + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2 default_server; + server_name _; + + ssl_certificate {{ nginx_ssl_certificate }}; + ssl_certificate_key {{ nginx_ssl_certificate_key }}; + include {{ nginx_snippets_path }}/ssl-params.conf; + + return 404; +}