Version 0.6.0 (2026-03-25)

Changes:
- add config validation container to docker-compose
- add Make file with `up` and `down` commands
- add `default-http{,s}-port` commands to add_servers.py
- 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 `https_custom_params` to templates
This commit is contained in:
2026-03-25 16:16:01 +03:00
parent 94259c5f99
commit 2ec44ea292
13 changed files with 266 additions and 55 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

6
Makefile Normal file
View File

@@ -0,0 +1,6 @@
up:
docker compose up --build -d
docker compose down validate-config
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,26 +7,46 @@ 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:
- nginx - nginx
restart: unless-stopped restart: unless-stopped
nginx: validate-config:
container_name: 'global-nginx' container_name: "global-nginx-validate"
build: ./nginx build: ./nginx
volumes: volumes: *volumes-config
- ssl:/ssl entrypoint: ["/usr/sbin/nginx", "-t"]
- letsencrypt:/etc/letsencrypt
nginx:
container_name: "global-nginx"
build: ./nginx
volumes: *volumes-config
depends_on:
validate-config:
condition: service_completed_successfully
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_ssliver: local
driver_opts:
type: "none"
o: "bind"
device: "./data/ssl"
letsencrypt: letsencrypt:
name: ssl_nginx_letsencrypt name: ssl_nginx_letsencryptdriver: local
driver_opts:
type: "none"
o: "bind"
device: "./data/letsencrypt"

View File

@@ -7,13 +7,16 @@ 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
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,13 +4,17 @@ from __future__ import annotations
import argparse import argparse
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field, fields
from typing import Any from typing import Any
import jinja2 import jinja2
import yaml import yaml
DEFAULT_HTTP_PORT = 80
DEFAULT_HTTPS_PORT = 80
@dataclass @dataclass
class Server: class Server:
"""Server entry for nginx.conf file.""" """Server entry for nginx.conf file."""
@@ -19,37 +23,25 @@ class Server:
all_names: str | None = None all_names: str | None = None
proxy_pass: str | None = None proxy_pass: str | None = None
certificate_dir: str | None = None certificate_dir: str | None = None
port: int = None http_port: int = None
ssl_port: int = None https_port: int = None
https_custom_params: str = None
server_options: list[str] = field(default_factory=list) server_options: list[str] = field(default_factory=list)
location_options: list[str] = field(default_factory=list) location_options: list[str] = field(default_factory=list)
certificates_path: str = "/etc/letsencrypt/live"
def __post_init__(self) -> None: 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: if self.all_names is None:
self.all_names = self.name self.all_names = self.name
if self.port is None: if self.http_port is None:
self.port = 80 self.http_port = DEFAULT_HTTP_PORT
if self.ssl_port is None: if self.https_port is None:
self.ssl_port = 443 self.https_port = DEFAULT_HTTPS_PORT
if self.https_custom_params is None:
self.https_custom_params = ""
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 {field.name: getattr(self, field.name) for field in fields(self)}
"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 +51,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 +63,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 +75,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 +103,11 @@ def main() -> None:
args: CLIParams = parser.parse_args() args: CLIParams = parser.parse_args()
global DEFAULT_HTTP_PORT
DEFAULT_HTTP_PORT = args.default_http_port
global DEFAULT_HTTPS_PORT
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 = [
@@ -126,12 +137,16 @@ def main() -> None:
), ),
proxy_pass=params.get("proxy_pass"), proxy_pass=params.get("proxy_pass"),
certificate_dir=_get_certificate_path( certificate_dir=_get_certificate_path(
args.http_only, domains_with_certs, args.certificates_path, params.get("certificate_name") or server_name 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,
), ),
port=params.get("port"), https_custom_params=params.get("https_custom_params"),
ssl_port=params.get("ssl_port"), http_port=params.get("http_port"),
server_options=params.get("server_options"), https_port=params.get("https_port"),
location_options=params.get("location_options"), server_options=params.get("server_options", []),
location_options=params.get("location_options", []),
) )
) )
for domain in domains_with_certs: for domain in domains_with_certs:

View File

@@ -1,8 +1,23 @@
{#- 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 ~ other.doma.in
- server_options
- opt_1;
...
- location_options
- opt_1;
...
- http_port ~ 80
- https_port ~ 443
- https_custom_params ~ http2
-#}
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 +46,9 @@ http {
{%- for server in servers %} {%- for server in servers %}
{# #} {# #}
server { server {
listen {{ server["port"] or 80 }}; listen {{ server["http_port"] }};
{%- if server["certificate_dir"] is not none %} {%- if server["certificate_dir"] is not none %}
listen {{ server["ssl_port"] or 443 }} ssl; listen {{ server["https_port"] }} ssl {{- "" if server["ssl_custom_params"]|length == 0 else " " + server["ssl_custom_params"]}};
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;
@@ -68,6 +83,9 @@ 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"]|length > 0 %}

View File

@@ -0,0 +1,126 @@
{# 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 ~ other.doma.in
- server_options
- opt_1;
...
- location_options
- opt_1;
...
- http_port ~ 80
- https_port ~ 443
- https_custom_params ~ http2
-#}
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 {
listen {{ server["http_port"] or 80 }};
{%- if server["certificate_dir"] is not none %}
listen {{ server["https_port"] }} ssl {{- "" if server["ssl_custom_params"]|length == 0 else " " + server["ssl_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 %}
}

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