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
This commit is contained in:
2026-03-25 16:16:01 +03:00
parent 94259c5f99
commit 3f56d6d16a
13 changed files with 287 additions and 78 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
/.venv

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@
/nginx.conf /nginx.conf
*.env *.env
/.venv /.venv
/docker-compose.y*ml /docker-compose.y*ml
/data

8
Makefile Normal file
View File

@@ -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

View File

@@ -1,9 +1,13 @@
# ssl_nginx hosting # ssl_nginx hosting
Version 0.6.0 (2026-03-25)
## Preparation ## Preparation
1. Copy and edit (if needed) [nginx/nginx.conf.j2](nginx/nginx.conf.j2.example) file. 1. Create and fill `nginx/nginx.conf.j2` jinja template file ([example for global nginx](./nginx/examples/nginx.conf.j2)
2. Add your certified domains to nginx/domains.txt file ([example](nginx/domains.txt.example)). 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 These domains will be used by _certbot_ to monitor and update (if possible) certificates. _Nginx_ will also setup
http server for the given entries. 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 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). 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. 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. 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 After it, `docker compose up` (or `make up` to also remove validation container) should do the trick.
on each seventh day of month. (set in Dockerfile of _certbot_ and _nginx_). 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 ## certbot_manual.sh
This sceipt is available to perform manual certificates obtaining. One can use it to get a 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). wildcard certificate for example (not available for automatic generation without an appropriate plugin).

View File

@@ -7,7 +7,7 @@ services:
context: ./certbot context: ./certbot
args: args:
EMAIL: ${EMAIL} EMAIL: ${EMAIL}
volumes: volumes: &volumes-config
- ssl:/ssl - ssl:/ssl
- letsencrypt:/etc/letsencrypt - letsencrypt:/etc/letsencrypt
depends_on: depends_on:
@@ -15,18 +15,31 @@ services:
restart: unless-stopped restart: unless-stopped
nginx: nginx:
container_name: 'global-nginx' container_name: "global-nginx"
build: ./nginx build: ./nginx
volumes: volumes: *volumes-config
- ssl:/ssl
- letsencrypt:/etc/letsencrypt
ports: ports:
- 80:80 - 80:80
- 443:443 - 443:443
restart: unless-stopped restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
volumes: volumes:
ssl: ssl:
name: ssl_nginx_ssl name: ssl_nginx_ssl
driver: local
driver_opts:
type: "none"
o: "bind"
device: "./data/ssl"
letsencrypt: letsencrypt:
name: ssl_nginx_letsencrypt name: ssl_nginx_letsencrypt
driver: local
driver_opts:
type: "none"
o: "bind"
device: "./data/letsencrypt"

View File

@@ -7,13 +7,17 @@ COPY servers.yaml /servers.yaml
COPY domains.txt /domains.txt COPY domains.txt /domains.txt
COPY nginx.conf.j2 /nginx.conf.j2 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 # service
FROM nginx:alpine FROM nginx:alpine
RUN apk add curl bash
COPY --from=builder /nginx.conf /etc/nginx/nginx.conf 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 COPY domains.txt /domains.txt
RUN echo "20 2 */7 * * nginx -s reload" > /etc/crontabs/root && \ RUN echo "20 2 */7 * * nginx -s reload" > /etc/crontabs/root && \

View File

@@ -4,52 +4,43 @@ from __future__ import annotations
import argparse import argparse
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any from typing import Any
import jinja2 import jinja2
import yaml import yaml
@dataclass
class Server: class Server:
"""Server entry for nginx.conf file.""" """Server entry for nginx.conf file."""
name: str DEFAULT_HTTP_PORT = 80
all_names: str | None = None DEFAULT_HTTPS_PORT = 443
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)
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: def __getattr__(self, name) -> Any:
if self.server_options is None: return self._params.get(name)
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 to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert server class to dict for nginx.conf.j2 jinja2-template""" """Convert server class to dict for nginx.conf.j2 jinja2-template"""
return { return dict(self._params)
"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,
}
@dataclass @dataclass
@@ -59,6 +50,8 @@ class CLIParams:
nginx_template: str nginx_template: str
domains_list_txt: str domains_list_txt: str
servers_config: str servers_config: str
default_http_port: str
default_https_port: str
certificates_path: str certificates_path: str
http_only: bool http_only: bool
output: str | None 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 = 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("--nginx-template", "-f", required=True, help="Path to nginx.conf.j2 template file")
parser.add_argument( parser.add_argument(
"--domains_list_txt", "--domains-list-txt",
"-d", "-d",
required=True, required=True,
help="Path to file with domains which have ssl certificates", help="Path to file with domains which have ssl certificates",
@@ -81,6 +74,18 @@ def main() -> None:
default=None, default=None,
help="Path to servers configuration yaml file", 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( parser.add_argument(
"--certificates-path", "--certificates-path",
"-c", "-c",
@@ -97,6 +102,9 @@ def main() -> None:
args: CLIParams = parser.parse_args() 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: if args.domains_list_txt is not None:
with open(args.domains_list_txt, "r", encoding="utf-8") as file: with open(args.domains_list_txt, "r", encoding="utf-8") as file:
domains_with_certs = [ domains_with_certs = [
@@ -117,22 +125,21 @@ def main() -> None:
servers: dict[str, dict[str, Any]] = data["servers"] servers: dict[str, dict[str, Any]] = data["servers"]
for server_name, params in servers.items(): 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( nginx_servers.append(
Server( Server(name=server_name, all_names=all_names, certificate_dir=certificate_dir, **params_part)
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"),
)
) )
for domain in domains_with_certs: for domain in domains_with_certs:
if not any( if not any(
@@ -140,8 +147,7 @@ def main() -> None:
(server.all_names is None and domain == server.name) (server.all_names is None and domain == server.name)
or ( or (
server.all_names is not None server.all_names is not None
and f" {domain}" in server.all_names and (f" {domain}" in server.all_names or server.all_names.startswith(domain))
or server.all_names.startswith(domain)
) )
) )
for server in nginx_servers for server in nginx_servers

View File

@@ -1,8 +1,24 @@
{#- variables ~ examples: #} {# input variables ~ examples:
{#- acme_challenge_location ~ /ssl/: #} - acme_challenge_location ~ /ssl/:
{#- resolver ~ 127.0.0.11: #} - resolver ~ 127.0.0.11:
{#- servers ~ ["name": ..., ("proxy_pass": ..., "server_options": ..., "location_options": ..., "all_names": ..., "port": ..., "ssl_port": ...)]: #} - servers
{#- #}user nginx; - 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; worker_processes auto;
error_log /var/log/nginx/error.log notice; error_log /var/log/nginx/error.log notice;
@@ -31,9 +47,12 @@ http {
{%- for server in servers %} {%- for server in servers %}
{# #} {# #}
server { server {
listen {{ server["port"] or 80 }}; {%- if server["http_port"] %}
{%- if server["certificate_dir"] is not none %} listen {{ server["http_port"] }} {{- " " + server["http_custom_params"] if server["http_custom_params"] else ""}};
listen {{ server["ssl_port"] or 443 }} ssl; {%- 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 {{ server["certificate_dir"] }}/fullchain.pem;
ssl_certificate_key {{ server["certificate_dir"] }}/privkey.pem; ssl_certificate_key {{ server["certificate_dir"] }}/privkey.pem;
@@ -45,21 +64,21 @@ http {
server_name {{ server["all_names"] or server["name"] }}; server_name {{ server["all_names"] or server["name"] }};
{%- if acme_challenge_location is not none %} {%- if acme_challenge_location %}
{# #} {# #}
location /.well-known/acme-challenge { location /.well-known/acme-challenge {
root {{ acme_challenge_location }}; root {{ acme_challenge_location }};
} }
{%- endif %} {%- endif %}
{%- if server["server_options"]|length > 0 %} {%- if server["server_options"] %}
{# #} {# #}
{%- for server_option in server["server_options"] %} {%- for server_option in server["server_options"] %}
{{ server_option }} {{ server_option }}
{%- endfor %} {%- endfor %}
{%- endif %} {%- endif %}
{%- if server["proxy_pass"] is not none %} {%- if server["proxy_pass"] %}
{# #} {# #}
location / { location / {
resolver {{ resolver }}; resolver {{ resolver }};
@@ -68,9 +87,12 @@ http {
proxy_set_header Host $host:$server_port; proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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; 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"] %} {%- for location_option in server["location_options"] %}
{{ location_option }} {{ location_option }}
@@ -81,3 +103,5 @@ http {
} }
{%- endfor %} {%- endfor %}
} }
include /etc/nginx/conf.d/*.conf;

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
resolver: 127.0.0.1 resolver: 127.0.0.1 # 127.0.0.11 for docker
acme_challenge_location: /etc/nginx/acme/ acme_challenge_location: /etc/nginx/acme/ # /ssl for docker volume
servers: servers:
@@ -18,8 +18,14 @@ servers:
"*.doma.in": "*.doma.in":
all_names: "*.doma.in doma.in" all_names: "*.doma.in doma.in"
certificate_name: doma.in certificate_name: doma.in
proxy_pass: "http://full.subdomain.proxy" proxy_pass: "http://full.subdomain.proxy"
server_options: server_options:
- "proxy_buffering off;" - "proxy_buffering off;"
- "proxy_request_buffering off;" - "proxy_request_buffering off;"
- "client_max_body_size 0;" - "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"

View File

@@ -0,0 +1,2 @@
/.gitignore
/.dockerignore

3
nginx/nginx_conf.d/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!/.gitignore
!/.dockerignore