Intro
A local TLS lab lets you practice certificate generation, server configuration, and debugging without risking production. In this guide you will:
- Create a local certificate authority (CA)
- Issue a server certificate for app.local (127.0.0.1)
- Configure an Nginx reverse proxy with TLS and secure defaults
- Validate with curl and OpenSSL
- Optionally add mutual TLS (client certs)
- See how to extend the same artifacts to Kubernetes Ingress
The focus is a narrow, testable setup you can fully inspect on your machine. Keep it local, keep it simple, and add complexity only after you have reliable checks.
Workflow Overview
A clear, staged workflow reduces rework and speeds up learning:
- Name your local endpoints
- Choose hostnames that never resolve publicly, for example:
- app.local, api.local
- Map them to 127.0.0.1 in your hosts file:
- Linux/macOS: /etc/hosts
- Windows: C:\\Windows\\System32\\drivers\\etc\\hosts
Example entries:
127.0.0.1 app.local
127.0.0.1 api.local
You have two common options. Pick one.
- Create a local CA and issue a server cert
Option A: OpenSSL (works everywhere)
# 1) Create a root CA (private!)
mkdir -p certs
openssl genrsa -out certs/rootCA.key 4096
openssl req -x509 -new -nodes -key certs/rootCA.key -sha256 -days 3650 \
-out certs/rootCA.crt -subj "/CN=lab-root-ca"
# 2) Create a server key and CSR for app.local
openssl genrsa -out certs/app.local.key 2048
openssl req -new -key certs/app.local.key -out certs/app.local.csr \
-subj "/CN=app.local"
# 3) Define SANs and usage for the server cert
cat > certs/app.local.ext <<'EOF'
subjectAltName = @alt_names
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
[alt_names]
DNS.1 = app.local
DNS.2 = localhost
IP.1 = 127.0.0.1
EOF
# 4) Sign the server CSR with your CA
openssl x509 -req -in certs/app.local.csr -CA certs/rootCA.crt -CAkey certs/rootCA.key \
-CAcreateserial -out certs/app.local.crt -days 825 -sha256 -extfile certs/app.local.ext
Option B: mkcert (convenient local CA)
# Initialize local trust store for mkcert
mkcert -install
# Issue a cert that covers your names and loopback
mkcert -cert-file certs/app.local.crt -key-file certs/app.local.key \
app.local 127.0.0.1 localhost
# mkcert also creates its root CA (location varies by OS)
Create a minimal Nginx server that terminates TLS and serves a simple response. Adjust paths as needed.
- Configure Nginx with TLS
nginx.conf (or a site file you include):
server {
listen 443 ssl http2;
server_name app.local;
ssl_certificate /absolute/path/certs/app.local.crt;
ssl_certificate_key /absolute/path/certs/app.local.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384: ECDHE-RSA-AES256-GCM-SHA384: ECDHE-ECDSA-CHACHA20-POLY1305: ECDHE-RSA-CHACHA20-POLY1305: ECDHE-ECDSA-AES128-GCM-SHA256: ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers on;
ssl_session_cache shared: SSL:10m;
ssl_session_timeout 1h;
# HSTS for lab: safe if you only use app.local locally
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
return 200 'hello tls lab\n';
add_header Content-Type text/plain;
}
}
server {
listen 80;
server_name app.local;
return 301 https://$host$request_uri;
}
Reload Nginx after testing the config:
nginx -t && nginx -s reload
# or with systemd
# sudo systemctl reload nginx
You can trust your root CA system-wide (optional) or point tools to it explicitly.
- Validate with curl and OpenSSL
System trust (optional; requires admin privileges):
- macOS:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/rootCA.crt
- Debian/Ubuntu:
sudo cp certs/rootCA.crt /usr/local/share/ca-certificates/lab-root-ca.crt
sudo update-ca-certificates
- Windows (Run as Administrator in cmd or PowerShell):
certutil -addstore -f "Root" certs\rootCA.crt
Direct validation without system trust:
# Use hosts file and CA explicitly
curl -vkI https://app.local --cacert certs/rootCA.crt
# Inspect the served certificate
openssl s_client -connect app.local:443 -servername app.local -showcerts < /dev/null \
| openssl x509 -noout -subject -issuer -dates
- Optional: enable mutual TLS (client certs)
# Create a client key and CSR
openssl genrsa -out certs/client.key 2048
openssl req -new -key certs/client.key -out certs/client.csr -subj "/CN=lab-client"
# Minimal client cert extensions
cat > certs/client.ext <<'EOF'
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
EOF
# Sign client cert
openssl x509 -req -in certs/client.csr -CA certs/rootCA.crt -CAkey certs/rootCA.key \
-CAcreateserial -out certs/client.crt -days 365 -sha256 -extfile certs/client.ext
Nginx mTLS snippet:
ssl_client_certificate /absolute/path/certs/rootCA.crt;
ssl_verify_client optional; # or 'on' to require all requests
location /secure/ {
if ($ssl_client_verify != SUCCESS) { return 403; }
return 200 'hello mtls client\n';
add_header Content-Type text/plain;
}
Test mTLS:
# Without client cert: expect 403 for /secure/
curl -vk https://app.local/secure/ --cacert certs/rootCA.crt
# With client cert: expect 200
curl -vk https://app.local/secure/ --cacert certs/rootCA.crt \
--cert certs/client.crt --key certs/client.key
If you already run a local cluster (kind, minikube, etc.) with an Ingress controller, reuse the same cert and key.
- Extend to Kubernetes Ingress (optional)
Create a TLS secret:
kubectl create namespace demo
kubectl -n demo create secret tls app-local-tls \
--cert=certs/app.local.crt --key=certs/app.local.key
Ingress manifest (nginx Ingress example):
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-local
namespace: demo
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- app.local
secretName: app-local-tls
rules:
- host: app.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app-svc
port:
number: 80
Point app.local to the Ingress endpoint (NodePort, LoadBalancer, or port-forward). Example with port-forwarding the controller to localhost:
# Adjust names to your controller service
kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 8443:443
# Then:
curl -vkI https://app.local:8443 --resolve app.local:8443:127.0.0.1 --cacert certs/rootCA.crt
- Rotate certificates safely
- Issue a new server cert with the same SANs
- Update file paths atomically (or overwrite the files)
- Validate Nginx config and reload
openssl x509 -in certs/app.local.crt -noout -enddate
nginx -t && nginx -s reload
- Troubleshooting quick wins
- Hostname mismatch: check SANs with
openssl x509 -in certs/app.local.crt -noout -text | grep -A1 "Subject Alternative Name" - Wrong chain: confirm issuer with
-issuerand ensure the server sends the correct cert - Protocol/cipher:
curl -v --tls-max 1.2 https://app.localto force-check versions - Nginx logs: check error log for handshake or certificate errors
Local Pilot Plan
Goal: one hostname (app.local) over TLS on localhost with measurable checks.
Scope
- Single server: Nginx on 127.0.0.1
- One hostname: app.local
- One CA, one server cert
Steps
- Add hosts entry for app.local -> 127.0.0.1
- Create a local CA and a server cert (OpenSSL commands above)
- Configure Nginx with TLS
- Validate with curl and OpenSSL
Success criteria (measurable)
curl -skI https://app.local --cacert certs/rootCA.crtreturns HTTP/1.1 or HTTP/2 200 OK for the TLS vhostopenssl s_client -connect app.local:443 -servername app.localshows a chain with issuer = lab-root-ca and a valid Not After date in the futurenginx -tsucceeds andnginx -s reloadcompletes without errors
Timebox
- 45 to 60 minutes end to end, including validation
Optional stretch
- Add mTLS for
/secure/and verify 403 without a client cert and 200 with one
Next extension after success
- Add api.local as a second hostname using the same CA and repeat the checks
Conclusion
You built a small, safe TLS lab that you can reuse for testing, troubleshooting, and experiments. You created a local CA, issued server and client certificates, configured Nginx with secure defaults, and validated behavior with curl and OpenSSL. Keep improvements incremental: script cert issuance and rotation, add more hostnames, and optionally reuse the same artifacts in a Kubernetes Ingress. Always define clear, local checks so you can verify each change before moving on.