Skip to main content

Building a Secure NGINX Reverse Proxy with Automated Internal TLS Certificate Renewal

·914 words·5 mins
Author
Ifesinachi Osude
Writing about infrastructure, automation, observability, networking, security, and homelab engineering.

Install OKD on Bare Metal

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

Enable NGINX:

systemctl enable --now nginx

Internal Root CA Layout
#

This guide assumes the internal CA already exists:

/opt/certs/root/
├── certs/
│   └── ca.cert.pem
└── private/
    └── ca.key.pem

If the CA private key is encrypted, create a password file:

mkdir -p /etc/certs
vi /etc/certs/root-ca.pass

Secure it:

chown root:root /etc/certs/root-ca.pass
chmod 600 /etc/certs/root-ca.pass

Create the Application Certificate Inventory
#

Create the CSV inventory file:

mkdir -p /etc/certs
vi /etc/certs/app-certs.csv

Example:

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.pem

Each 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.sh

Paste 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."
fi

Make it executable:

chmod 750 /usr/local/sbin/renew-app-certs.sh

Configure 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-renewal

Create Basic Authentication
#

Create the auth directory:

mkdir -p /etc/nginx/auth

Create a user:

htpasswd -c /etc/nginx/auth/loki.htpasswd lokiadmin

Configure NGINX
#

Create the Loki proxy:

vi /etc/nginx/conf.d/loki.conf

Example 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.sh

Validate:

openssl x509 -in /etc/nginx/certs/loki.cert.pem -noout -subject -issuer -dates

Test NGINX:

nginx -t
systemctl reload nginx

Automate 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
EOF

Restart cron:

systemctl restart crond

Final 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.