Skip to main content

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

·914 words·5 mins
Ifesinachi Osude
Author
Ifesinachi Osude
I build production infrastructure for a living — and a private cloud in my closet for fun.

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 10.20.30.10: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.