From 3f56d6d16a6e02ae66a4ab2fd578209c8bee63d0 Mon Sep 17 00:00:00 2001 From: Aleksei Sokol Date: Wed, 25 Mar 2026 16:16:01 +0300 Subject: [PATCH] Version 0.6.0 (2026-04-01) Changes: - add config validation option via Makefile - add `default-http{,s}-port` commands to add_servers.py - update add_servers.py to pass generic parameters to servers in templates - add nginx_conf.d directory usage for more than one custom nginx configurations - rename `port` and `ssl_port` to `http{,s}_port` for templates - add `http{,s}_custom_params` to templates --- .dockerignore | 1 + .gitignore | 3 +- Makefile | 8 ++ README.md | 22 ++- docker-compose.yaml.example | 23 ++- nginx/Dockerfile | 6 +- nginx/add_servers.py | 106 +++++++------- .../domains.txt} | 0 .../nginx.conf.j2} | 48 +++++-- nginx/examples/nginx.middle.conf.j2 | 131 ++++++++++++++++++ .../servers.yaml} | 12 +- nginx/nginx_conf.d/.dockerignore | 2 + nginx/nginx_conf.d/.gitignore | 3 + 13 files changed, 287 insertions(+), 78 deletions(-) create mode 100644 .dockerignore create mode 100644 Makefile rename nginx/{domains.txt.example => examples/domains.txt} (100%) rename nginx/{nginx.conf.j2.example => examples/nginx.conf.j2} (57%) create mode 100644 nginx/examples/nginx.middle.conf.j2 rename nginx/{servers.yaml.example => examples/servers.yaml} (66%) create mode 100644 nginx/nginx_conf.d/.dockerignore create mode 100644 nginx/nginx_conf.d/.gitignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..87a15c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +/.venv diff --git a/.gitignore b/.gitignore index 4dc62f2..b76713d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /nginx.conf *.env /.venv -/docker-compose.y*ml \ No newline at end of file +/docker-compose.y*ml +/data diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bdcdd92 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +up: + # this script only starts nginx, make sure that certbot is launched as well + docker compose build + docker compose run --rm --name validate-config --entrypoint "/usr/sbin/nginx" nginx -t + docker compose up -d nginx + +down: + docker compose down diff --git a/README.md b/README.md index 44d53fb..6c100f9 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # ssl_nginx hosting +Version 0.6.0 (2026-03-25) + ## Preparation -1. Copy and edit (if needed) [nginx/nginx.conf.j2](nginx/nginx.conf.j2.example) file. -2. Add your certified domains to nginx/domains.txt file ([example](nginx/domains.txt.example)). +1. Create and fill `nginx/nginx.conf.j2` jinja template file ([example for global nginx](./nginx/examples/nginx.conf.j2) +and for [nginx between other nginx and backends](./nginx/examples/nginx.middle.conf.j2)). For most cases you would +not need to change the tempalte, but for sure it is not included by default. +2. Add your certified domains to `nginx/domains.txt` file ([example](nginx/examples/domains.txt)). These domains will be used by _certbot_ to monitor and update (if possible) certificates. _Nginx_ will also setup http server for the given entries. @@ -12,7 +16,10 @@ use https with certificates at the given path. That will fail nginx startup if the certificate to at least one domain is missing (--http-only option will skip domains.txt check). -3. Add your servers section configuration to nginx/servers.yaml ([example](nginx/servers.yaml.example)). +3. Add your servers section configuration to `nginx/servers.yaml` ([example](nginx/examples/servers.yaml)). +4. Set `EMAIL` value to .env file for certbot configuration +5. Copy and tune [docker-compose.yaml example](./docker-compose.yaml.example) +6. Optionally, if additional nginx configurations are used, they can be placed to `nginx/nginx_conf.d` directory User email used for certbot can be set as environment variable at build process or in .env file. @@ -22,10 +29,13 @@ For the first time you should run (run_once.d-c.yml)[run_once.d-c.yml] docker-co You can use (run_once.sh)[run_once.sh] script for this. -After it, `docker compose up` should do the trick. Certificates update attempt will be performed automatically at 02:15 - on each seventh day of month. (set in Dockerfile of _certbot_ and _nginx_). +After it, `docker compose up` (or `make up` to also remove validation container) should do the trick. +Certificates update attempt will be performed automatically at 02:15 on each seventh day of month. (set in Dockerfile of _certbot_ and _nginx_). + +On launch config is validated with correct certificates and environment in `validate-config` step, so there should not be a case when incorrect +config replaces correct one on update attempt. ## certbot_manual.sh This sceipt is available to perform manual certificates obtaining. One can use it to get a - wildcard certificate for example (not available for automatic generation without an appropriate plugin). \ No newline at end of file + wildcard certificate for example (not available for automatic generation without an appropriate plugin). diff --git a/docker-compose.yaml.example b/docker-compose.yaml.example index 9e63a79..ddce0f6 100644 --- a/docker-compose.yaml.example +++ b/docker-compose.yaml.example @@ -7,7 +7,7 @@ services: context: ./certbot args: EMAIL: ${EMAIL} - volumes: + volumes: &volumes-config - ssl:/ssl - letsencrypt:/etc/letsencrypt depends_on: @@ -15,18 +15,31 @@ services: restart: unless-stopped nginx: - container_name: 'global-nginx' + container_name: "global-nginx" build: ./nginx - volumes: - - ssl:/ssl - - letsencrypt:/etc/letsencrypt + volumes: *volumes-config ports: - 80:80 - 443:443 restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "50m" volumes: ssl: name: ssl_nginx_ssl + driver: local + driver_opts: + type: "none" + o: "bind" + device: "./data/ssl" + letsencrypt: name: ssl_nginx_letsencrypt + driver: local + driver_opts: + type: "none" + o: "bind" + device: "./data/letsencrypt" diff --git a/nginx/Dockerfile b/nginx/Dockerfile index cbda994..d8fedcd 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -7,13 +7,17 @@ COPY servers.yaml /servers.yaml COPY domains.txt /domains.txt COPY nginx.conf.j2 /nginx.conf.j2 -RUN python /add_servers.py --nginx-template /nginx.conf.j2 -o /nginx.conf --domains_list_txt /domains.txt --servers-config servers.yaml --certificates-path /ssl +RUN python /add_servers.py --nginx-template /nginx.conf.j2 -o /nginx.conf --domains-list-txt /domains.txt --servers-config servers.yaml --certificates-path /ssl # service FROM nginx:alpine +RUN apk add curl bash + COPY --from=builder /nginx.conf /etc/nginx/nginx.conf +RUN rm -rf /etc/nginx/conf.d +COPY nginx_conf.d /etc/nginx/conf.d COPY domains.txt /domains.txt RUN echo "20 2 */7 * * nginx -s reload" > /etc/crontabs/root && \ diff --git a/nginx/add_servers.py b/nginx/add_servers.py index b725ea4..3fed721 100644 --- a/nginx/add_servers.py +++ b/nginx/add_servers.py @@ -4,52 +4,43 @@ from __future__ import annotations import argparse import os -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any import jinja2 import yaml -@dataclass + class Server: """Server entry for nginx.conf file.""" - name: str - all_names: str | None = None - proxy_pass: str | None = None - certificate_dir: str | None = None - port: int = None - ssl_port: int = None - server_options: list[str] = field(default_factory=list) - location_options: list[str] = field(default_factory=list) + DEFAULT_HTTP_PORT = 80 + DEFAULT_HTTPS_PORT = 443 - certificates_path: str = "/etc/letsencrypt/live" + def __init__( + self, + http_port: int = ..., + https_port: int = ..., + **kwargs: dict[str, Any], + ): + if http_port is ...: + http_port = Server.DEFAULT_HTTP_PORT + if https_port is ...: + https_port = Server.DEFAULT_HTTPS_PORT + self._params = dict(kwargs) + for additional_name, additional_value in zip( + ["http_port", "https_port"], + [http_port, https_port], + ): + self._params[additional_name] = additional_value - def __post_init__(self) -> None: - if self.server_options is None: - self.server_options = [] - if self.location_options is None: - self.location_options = [] - if self.all_names is None: - self.all_names = self.name - if self.port is None: - self.port = 80 - if self.ssl_port is None: - self.ssl_port = 443 + def __getattr__(self, name) -> Any: + return self._params.get(name) def to_dict(self) -> dict: """Convert server class to dict for nginx.conf.j2 jinja2-template""" - return { - "name": self.name, - "all_names": self.all_names, - "proxy_pass": self.proxy_pass, - "server_options": self.server_options, - "location_options": self.location_options, - "certificate_dir": self.certificate_dir, - "port": self.port, - "ssl_port": self.ssl_port, - } + return dict(self._params) @dataclass @@ -59,6 +50,8 @@ class CLIParams: nginx_template: str domains_list_txt: str servers_config: str + default_http_port: str + default_https_port: str certificates_path: str http_only: bool output: str | None @@ -69,7 +62,7 @@ def main() -> None: parser = argparse.ArgumentParser("add-servers", description="Add domain servers to a given nginx.conf file") parser.add_argument("--nginx-template", "-f", required=True, help="Path to nginx.conf.j2 template file") parser.add_argument( - "--domains_list_txt", + "--domains-list-txt", "-d", required=True, help="Path to file with domains which have ssl certificates", @@ -81,6 +74,18 @@ def main() -> None: default=None, help="Path to servers configuration yaml file", ) + parser.add_argument( + "--default-http-port", + required=False, + default=80, + help="Default port for https protocol", + ) + parser.add_argument( + "--default-https-port", + required=False, + default=443, + help="Default port for https protocol", + ) parser.add_argument( "--certificates-path", "-c", @@ -97,6 +102,9 @@ def main() -> None: args: CLIParams = parser.parse_args() + Server.DEFAULT_HTTP_PORT = args.default_http_port + Server.DEFAULT_HTTPS_PORT = args.default_https_port + if args.domains_list_txt is not None: with open(args.domains_list_txt, "r", encoding="utf-8") as file: domains_with_certs = [ @@ -117,22 +125,21 @@ def main() -> None: servers: dict[str, dict[str, Any]] = data["servers"] for server_name, params in servers.items(): + params_part = dict(params) + all_names = params.get( + "all_names", + None if "*" not in server_name else f"{server_name} {server_name.replace('*.', '', 1)}", + ) + certificate_dir = _get_certificate_path( + http_only=args.http_only, + domains_with_certs=domains_with_certs, + base_certs_path=args.certificates_path, + server_name=params.get("certificate_name") or server_name, + ) + params_part.pop("all_names", None) + params_part.pop("certificate_name", None) nginx_servers.append( - Server( - name=server_name, - all_names=params.get( - "all_names", - None if "*" not in server_name else f"{server_name} {server_name.replace('*.', '', 1)}", - ), - proxy_pass=params.get("proxy_pass"), - certificate_dir=_get_certificate_path( - args.http_only, domains_with_certs, args.certificates_path, params.get("certificate_name") or server_name - ), - port=params.get("port"), - ssl_port=params.get("ssl_port"), - server_options=params.get("server_options"), - location_options=params.get("location_options"), - ) + Server(name=server_name, all_names=all_names, certificate_dir=certificate_dir, **params_part) ) for domain in domains_with_certs: if not any( @@ -140,8 +147,7 @@ def main() -> None: (server.all_names is None and domain == server.name) or ( server.all_names is not None - and f" {domain}" in server.all_names - or server.all_names.startswith(domain) + and (f" {domain}" in server.all_names or server.all_names.startswith(domain)) ) ) for server in nginx_servers diff --git a/nginx/domains.txt.example b/nginx/examples/domains.txt similarity index 100% rename from nginx/domains.txt.example rename to nginx/examples/domains.txt diff --git a/nginx/nginx.conf.j2.example b/nginx/examples/nginx.conf.j2 similarity index 57% rename from nginx/nginx.conf.j2.example rename to nginx/examples/nginx.conf.j2 index 17f2ea1..1dd1db3 100644 --- a/nginx/nginx.conf.j2.example +++ b/nginx/examples/nginx.conf.j2 @@ -1,8 +1,24 @@ -{#- variables ~ examples: #} -{#- acme_challenge_location ~ /ssl/: #} -{#- resolver ~ 127.0.0.11: #} -{#- servers ~ ["name": ..., ("proxy_pass": ..., "server_options": ..., "location_options": ..., "all_names": ..., "port": ..., "ssl_port": ...)]: #} -{#- #}user nginx; +{# input variables ~ examples: +- acme_challenge_location ~ /ssl/: +- resolver ~ 127.0.0.11: +- servers + - name ~ doma.in, + - all_names ~ doma.in testing.doma.in + - proxy_pass ~ http://localhost:3333 + - certificate_dir ~ /ssl/other.doma.in (configured by certificate_name) + - server_options + - opt_1; + ... + - location_options + - opt_1; + ... + - http_port ~ 80 + - https_port ~ 443 + - http_custom_params ~ proxy_protocol + - https_custom_params ~ proxy_protocol +-#} + +user nginx; worker_processes auto; error_log /var/log/nginx/error.log notice; @@ -31,9 +47,12 @@ http { {%- for server in servers %} {# #} server { - listen {{ server["port"] or 80 }}; - {%- if server["certificate_dir"] is not none %} - listen {{ server["ssl_port"] or 443 }} ssl; + {%- if server["http_port"] %} + listen {{ server["http_port"] }} {{- " " + server["http_custom_params"] if server["http_custom_params"] else ""}}; + {%- endif %} + + {%- if server["certificate_dir"] %} + listen {{ server["https_port"] }} ssl {{- " " + server["https_custom_params"] if server["https_custom_params"] else ""}}; ssl_certificate {{ server["certificate_dir"] }}/fullchain.pem; ssl_certificate_key {{ server["certificate_dir"] }}/privkey.pem; @@ -45,21 +64,21 @@ http { server_name {{ server["all_names"] or server["name"] }}; - {%- if acme_challenge_location is not none %} + {%- if acme_challenge_location %} {# #} location /.well-known/acme-challenge { root {{ acme_challenge_location }}; } {%- endif %} - {%- if server["server_options"]|length > 0 %} + {%- if server["server_options"] %} {# #} {%- for server_option in server["server_options"] %} {{ server_option }} {%- endfor %} {%- endif %} - {%- if server["proxy_pass"] is not none %} + {%- if server["proxy_pass"] %} {# #} location / { resolver {{ resolver }}; @@ -68,9 +87,12 @@ http { proxy_set_header Host $host:$server_port; 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-Port $server_port; proxy_set_header X-Real-IP $remote_addr; - {%- if server["location_options"]|length > 0 %} + {%- if server["location_options"] %} {# #} {%- for location_option in server["location_options"] %} {{ location_option }} @@ -81,3 +103,5 @@ http { } {%- endfor %} } + +include /etc/nginx/conf.d/*.conf; diff --git a/nginx/examples/nginx.middle.conf.j2 b/nginx/examples/nginx.middle.conf.j2 new file mode 100644 index 0000000..081cf94 --- /dev/null +++ b/nginx/examples/nginx.middle.conf.j2 @@ -0,0 +1,131 @@ +{# input variables ~ examples: +- acme_challenge_location ~ /ssl/: +- resolver ~ 127.0.0.11: +- servers + - name ~ doma.in, + - all_names ~ doma.in testing.doma.in + - proxy_pass ~ http://localhost:3333 + - certificate_dir ~ /ssl/other.doma.in (configured by certificate_name parameter) + - server_options + - opt_1; + ... + - location_options + - opt_1; + ... + - http_port ~ 80 + - https_port ~ 443 + - http_custom_params ~ proxy_protocol + - https_custom_params ~ proxy_protocol +-#} + +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + server_tokens off; + gzip on; + + proxy_connect_timeout 300; + proxy_send_timeout 600; + proxy_read_timeout 600; + send_timeout 600; + client_max_body_size 500M; + + server { + return 404; + } + + map $http_host $proxied_host { + "" $host; + default $http_host; + } + map $http_x_forwarded_port $proxied_port { + "" $server_port; + default $http_x_forwarded_port; + } + map $http_x_forwarded_host $proxied_x_host { + "" $host:$proxied_port; + default $http_x_forwarded_host; + } + map $http_x_forwarded_proto $proxied_proto { + "" $scheme; + default $http_x_forwarded_proto; + } + map $http_x_real_ip $proxied_remote_addr { + "" $remote_addr; + default $http_x_real_ip; + } + map $http_x_forwarded_for $proxied_forwarded_for { + "" $proxy_add_x_forwarded_for; + default $http_x_forwarded_for; + } + {%- for server in servers %} + {# #} + server { + {%- if server["http_port"] is not none %} + listen {{ server["http_port"] }} {{- "" if server["http_custom_params"] is none or server["http_custom_params"]|length == 0 else " " + server["http_custom_params"]}}; + {%- endif %} + + {%- if server["certificate_dir"] is not none %} + listen {{ server["https_port"] }} ssl {{- "" if server["https_custom_params"] is none or server["https_custom_params"]|length == 0 else " " + server["https_custom_params"]}}; + ssl_certificate {{ server["certificate_dir"] }}/fullchain.pem; + ssl_certificate_key {{ server["certificate_dir"] }}/privkey.pem; + + if ($scheme = 'http') { + return 302 https://$host$request_uri; + } + {%- endif %} + keepalive_timeout 70; + + server_name {{ server["all_names"] or server["name"] }}; + + {%- if acme_challenge_location is not none %} + {# #} + location /.well-known/acme-challenge { + root {{ acme_challenge_location }}; + } + {%- endif %} + + {%- if server["server_options"]|length > 0 %} + {# #} + {%- for server_option in server["server_options"] %} + {{ server_option }} + {%- endfor %} + {%- endif %} + + {%- if server["proxy_pass"] is not none %} + {# #} + location / { + resolver {{ resolver }}; + set $host_{{ loop.index }} {{ server["proxy_pass"] }}; + proxy_pass $host_{{ loop.index }}; + + proxy_set_header HOST $proxied_host; + proxy_set_header X-Forwarded-Host $proxied_x_host; + proxy_set_header X-Forwarded-Port $proxied_port; + proxy_set_header X-Forwarded-Proto $proxied_proto; + proxy_set_header X-Forwarded-For $proxied_forwarded_for; + proxy_set_header X-Real-IP $proxied_remote_addr; + + {%- if server["location_options"]|length > 0 %} + {# #} + {%- for location_option in server["location_options"] %} + {{ location_option }} + {%- endfor %} + {%- endif %} + } + {%- endif %} + } + {%- endfor %} +} + +include /etc/nginx/conf.d/*.conf; diff --git a/nginx/servers.yaml.example b/nginx/examples/servers.yaml similarity index 66% rename from nginx/servers.yaml.example rename to nginx/examples/servers.yaml index 0c3dc4b..8a4ee8c 100644 --- a/nginx/servers.yaml.example +++ b/nginx/examples/servers.yaml @@ -1,5 +1,5 @@ -resolver: 127.0.0.1 -acme_challenge_location: /etc/nginx/acme/ +resolver: 127.0.0.1 # 127.0.0.11 for docker +acme_challenge_location: /etc/nginx/acme/ # /ssl for docker volume servers: @@ -18,8 +18,14 @@ servers: "*.doma.in": all_names: "*.doma.in doma.in" certificate_name: doma.in - proxy_pass: "http://full.subdomain.proxy" + proxy_pass: "http://full.subdomain.proxy" server_options: - "proxy_buffering off;" - "proxy_request_buffering off;" - "client_max_body_size 0;" + - "http2 on;" + "custom.doma.in": + proxy_pass: "http://custom" + http_port: 4480 + https_port: 4443 + https_custom_params: "http2 proxy_protocol" diff --git a/nginx/nginx_conf.d/.dockerignore b/nginx/nginx_conf.d/.dockerignore new file mode 100644 index 0000000..5216758 --- /dev/null +++ b/nginx/nginx_conf.d/.dockerignore @@ -0,0 +1,2 @@ +/.gitignore +/.dockerignore diff --git a/nginx/nginx_conf.d/.gitignore b/nginx/nginx_conf.d/.gitignore new file mode 100644 index 0000000..0c727a9 --- /dev/null +++ b/nginx/nginx_conf.d/.gitignore @@ -0,0 +1,3 @@ +* +!/.gitignore +!/.dockerignore