
Building a Secure NGINX Reverse Proxy with Automated Internal TLS Certificate Renewal #
In many internal environments, applications such as Loki, Tempo, Mimir, Prometheus, Grafana, or custom APIs need secure TLS access without relying on public certificate authorities.
This guide walks through building:
- An NGINX reverse proxy
- TLS certificates signed by an internal CA
- Automatic certificate renewal
- Multi-application support using CSV inventory
- SELinux-compatible configuration
- Basic Authentication support
- Automatic NGINX reloads after renewal
The result is a reusable internal PKI automation framework for Linux infrastructure.
Environment #
This guide assumes:
| Component | Value |
|---|---|
| OS | RHEL / AlmaLinux / Rocky Linux |
| Web Server | NGINX |
| SELinux | Enabled |
| Internal Root CA | OpenSSL-based |
| Certificate Storage | /etc/nginx/certs |
Install Required Packages #
dnf install -y \
nginx \
openssl \
policycoreutils-python-utils \
httpd-toolsEnable NGINX:
systemctl enable --now nginxInternal Root CA Layout #
This guide assumes the internal CA already exists:
/opt/certs/root/
├── certs/
│ └── ca.cert.pem
└── private/
└── ca.key.pemIf the CA private key is encrypted, create a password file:
mkdir -p /etc/certs
vi /etc/certs/root-ca.passSecure it:
chown root:root /etc/certs/root-ca.pass
chmod 600 /etc/certs/root-ca.passCreate the Application Certificate Inventory #
Create the CSV inventory file:
mkdir -p /etc/certs
vi /etc/certs/app-certs.csvExample:
name,hostname,ip,cert_path,key_path,chain_path
loki,loki.example.internal,[IP-address],/etc/nginx/certs/loki.cert.pem,/etc/nginx/certs/loki.key.pem,/etc/nginx/certs/loki.chain.pem
tempo,tempo.example.internal,[IP-address],/etc/nginx/certs/tempo.cert.pem,/etc/nginx/certs/tempo.key.pem,/etc/nginx/certs/tempo.chain.pem
mimir,mimir.example.internal,[IP-address],/etc/nginx/certs/mimir.cert.pem,/etc/nginx/certs/mimir.key.pem,/etc/nginx/certs/mimir.chain.pemEach row defines:
| Field | Description |
|---|---|
| name | Friendly application name |
| hostname | Certificate FQDN |
| ip | SAN IP address |
| cert_path | Signed certificate |
| key_path | Private key |
| chain_path | Full certificate chain |
Create the Certificate Renewal Script #
Create the script:
vi /usr/local/sbin/renew-app-certs.shPaste the following:
#!/usr/bin/env bash
set -euo pipefail
CSV_FILE="/etc/certs/app-certs.csv"
ROOT_CA_CERT="/opt/certs/root/certs/ca.cert.pem"
ROOT_CA_KEY="/opt/certs/root/private/ca.key.pem"
ROOT_CA_PASS_FILE="/etc/certs/root-ca.pass"
WORK_DIR="/var/lib/cert-renewal"
DAYS_VALID="825"
RENEW_BEFORE_DAYS="30"
RELOAD_NGINX=0
mkdir -p "${WORK_DIR}"
if [ ! -f "${CSV_FILE}" ]; then
echo "ERROR: CSV file not found: ${CSV_FILE}"
exit 1
fi
if [ ! -f "${ROOT_CA_CERT}" ]; then
echo "ERROR: Root CA certificate not found: ${ROOT_CA_CERT}"
exit 1
fi
if [ ! -f "${ROOT_CA_KEY}" ]; then
echo "ERROR: Root CA private key not found: ${ROOT_CA_KEY}"
exit 1
fi
if [ ! -f "${ROOT_CA_PASS_FILE}" ]; then
echo "ERROR: Root CA password file not found: ${ROOT_CA_PASS_FILE}"
exit 1
fi
renew_cert() {
local name="$1"
local hostname="$2"
local ip="$3"
local cert_path="$4"
local key_path="$5"
local chain_path="$6"
local cert_dir
local key_dir
local chain_dir
local csr_file
local cnf_file
cert_dir="$(dirname "${cert_path}")"
key_dir="$(dirname "${key_path}")"
chain_dir="$(dirname "${chain_path}")"
mkdir -p "${cert_dir}" "${key_dir}" "${chain_dir}"
csr_file="${WORK_DIR}/${name}.csr.pem"
cnf_file="${WORK_DIR}/${name}.openssl.cnf"
cat > "${cnf_file}" <<CNF
[ req ]
default_bits = 4096
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = req_ext
[ dn ]
CN = ${hostname}
O = Internal Infrastructure
OU = Platform Engineering
[ req_ext ]
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = ${hostname}
IP.1 = ${ip}
[ server_ext ]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
CNF
local needs_renewal=0
if [ ! -f "${cert_path}" ]; then
needs_renewal=1
elif ! openssl x509 \
-checkend $((RENEW_BEFORE_DAYS * 86400)) \
-noout \
-in "${cert_path}" >/dev/null 2>&1; then
needs_renewal=1
fi
if [ "${needs_renewal}" -eq 0 ]; then
echo "[${name}] Certificate valid for more than ${RENEW_BEFORE_DAYS} days. Skipping."
return 0
fi
echo "[${name}] Renewing certificate for ${hostname}..."
openssl genrsa -out "${key_path}" 4096
openssl req \
-new \
-key "${key_path}" \
-out "${csr_file}" \
-config "${cnf_file}"
openssl x509 \
-req \
-in "${csr_file}" \
-CA "${ROOT_CA_CERT}" \
-CAkey "${ROOT_CA_KEY}" \
-passin "file:${ROOT_CA_PASS_FILE}" \
-CAcreateserial \
-out "${cert_path}" \
-days "${DAYS_VALID}" \
-sha256 \
-extensions server_ext \
-extfile "${cnf_file}"
cat "${cert_path}" "${ROOT_CA_CERT}" > "${chain_path}"
chown root:nginx "${cert_path}" "${key_path}" "${chain_path}"
chmod 644 "${cert_path}" "${chain_path}"
chmod 640 "${key_path}"
restorecon -v "${cert_path}" "${key_path}" "${chain_path}" || true
echo "[${name}] Certificate renewed successfully."
RELOAD_NGINX=1
}
while IFS=',' read -r name hostname ip cert_path key_path chain_path; do
[ "${name}" = "name" ] && continue
[ -z "${name}" ] && continue
[[ "${name}" =~ ^# ]] && continue
renew_cert \
"${name}" \
"${hostname}" \
"${ip}" \
"${cert_path}" \
"${key_path}" \
"${chain_path}"
done < "${CSV_FILE}"
if [ "${RELOAD_NGINX}" -eq 1 ]; then
nginx -t
systemctl reload nginx
else
echo "No certificates renewed."
fiMake it executable:
chmod 750 /usr/local/sbin/renew-app-certs.shConfigure SELinux #
Apply SELinux contexts:
semanage fcontext -a -t cert_t "/etc/nginx/certs(/.*)?"
semanage fcontext -a -t cert_t "/etc/certs(/.*)?"
semanage fcontext -a -t var_lib_t "/var/lib/cert-renewal(/.*)?"
restorecon -Rv /etc/nginx/certs
restorecon -Rv /etc/certs
restorecon -Rv /var/lib/cert-renewalCreate Basic Authentication #
Create the auth directory:
mkdir -p /etc/nginx/authCreate a user:
htpasswd -c /etc/nginx/auth/loki.htpasswd lokiadminConfigure NGINX #
Create the Loki proxy:
vi /etc/nginx/conf.d/loki.confExample configuration:
upstream loki_backend {
server 127.0.0.1:3100;
keepalive 32;
}
server {
listen 80;
server_name loki.example.internal;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name loki.example.internal;
ssl_certificate /etc/nginx/certs/loki.chain.pem;
ssl_certificate_key /etc/nginx/certs/loki.key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
auth_basic "Protected";
auth_basic_user_file /etc/nginx/auth/loki.htpasswd;
location / {
proxy_pass http://loki_backend;
proxy_http_version 1.1;
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 https;
proxy_read_timeout 300s;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
}
}Test Certificate Generation #
Run the renewal script:
/usr/local/sbin/renew-app-certs.shValidate:
openssl x509 -in /etc/nginx/certs/loki.cert.pem -noout -subject -issuer -datesTest NGINX:
nginx -t
systemctl reload nginxAutomate Renewal with Cron #
Create a cron job:
cat > /etc/cron.d/renew-app-certs <<EOF
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * 0 root /usr/local/sbin/renew-app-certs.sh >> /var/log/renew-app-certs.log 2>&1
EOFRestart cron:
systemctl restart crondFinal Result #
This setup provides:
- Internal PKI automation
- Automatic TLS renewal
- Multi-application certificate management
- NGINX reverse proxy integration
- SELinux compatibility
- Basic authentication
- Centralized inventory-driven certificate management
It works well for:
- Loki
- Tempo
- Mimir
- Grafana
- Prometheus
- Internal APIs
- Homelab infrastructure
- Enterprise internal services
Conclusion #
Using an internal CA with automated renewal provides a lightweight alternative to public ACME infrastructure while maintaining full control over internal trust chains and certificate lifecycle management.
The CSV-driven inventory model also makes it easy to scale the setup across multiple services without duplicating configuration or scripts.