Configure mTLS between Nginx and a Node.js app using HashiCorp Vault for automatic certificate issuance
Redes & Segurança · By Kevin Marcondes · Published on · Updated on · 11 min read
This tutorial shows how to configure mutual TLS (mTLS) between Nginx and a Node.js application using HashiCorp Vault to automatically issue and renew certificates. You will create an internal PKI, configure AppRoles, use the Vault Agent to render keys and certs on disk, and require mutual verification on Nginx’s HTTPS upstream.
Architecture and goal of this guide
We will set up mTLS for the internal link where Nginx acts as the TLS client and Node.js is the TLS server. Vault will handle issuance and renewal through the Vault Agent, so Nginx presents a client certificate that Node verifies, and Nginx validates the Node server certificate.
This is a hands-on guide to configure mTLS between Nginx and a Node.js app using HashiCorp Vault for automated certificate issuance.
If you need mTLS between microservices running in Docker, also consult the step-by-step guide titled "mTLS in Docker Compose (Internal PKI)".
| Component | Certificates/Files | Purpose |
|---|---|---|
| Vault (PKI) | Root + intermediate CA | Sign and renew certificates |
| Nginx (client) | /etc/nginx/certs/nginx.crt and .key | Present client cert to Node.js |
| Node.js (server) | /etc/node/certs/server.crt and .key | Serve HTTPS and require client cert |
| Shared CA | /etc/*/certs/ca.pem | Trust the certificate chain |
Prerequisites and warnings
You need a Linux server with Nginx, Node.js (>= 18), Vault, the Vault CLI and OpenSSL installed. Use internal hostnames (for example nginx.local and node.local) resolving to the appropriate IPs.
Ports used in this guide: Vault (8200), Node (8443) and Nginx (80/443). Do not expose Vault without authentication and TLS. To harden the server, see the VPS security checklist and adjust your firewall as needed.
For an alternative reverse proxy, see the Traefik vs Nginx comparison.
- sudo/root access
- Text editor (nano/vi)
- Synchronized clock (NTP) to avoid certificate validity errors
- Disk space for keys/certs with restricted permissions
1) Start Vault and configure the PKI (root + intermediate + role)
For lab purposes we use Vault in dev mode to speed up the setup. Never use dev mode in production. In production you must initialize and unseal Vault properly and enable TLS for Vault access.
We will create a root CA, an intermediate CA and a role that allows issuing both client and server certificates.
# 1) Inicie o Vault (laboratório)
export VAULT_ADDR="http://127.0.0.1:8200"
nohup vault server -dev -dev-root-token-id=root >/tmp/vault-dev.log 2>&1 &
export VAULT_TOKEN="root"
# 2) Habilite PKI raiz e gere a CA
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki
vault write pki/root/generate/internal common_name="Demo Root CA" ttl=87600h
# 3) Habilite PKI intermediária e gere CSR
vault secrets enable -path=pki_int pki
vault secrets tune -max-lease-ttl=43800h pki_int
vault write -field=csr pki_int/intermediate/generate/internal \
common_name="Demo Intermediate CA" > /tmp/pki_intermediate.csr
# 4) Assine a intermediária com a raiz e instale
vault write -field=certificate pki/root/sign-intermediate \
csr=@/tmp/pki_intermediate.csr format=pem_bundle ttl=43800h \
> /tmp/intermediate.cert.pem
vault write pki_int/intermediate/set-signed \
certificate=@/tmp/intermediate.cert.pem
# 5) Crie role para emitir certificados de cliente e servidor
vault write pki_int/roles/svc \
allow_any_name=true \
key_type=ec key_bits=256 \
server_flag=true client_flag=true \
max_ttl=720h require_cn=false
# 6) Opcional: exporte cadeia da CA intermediária
vault read -field=certificate pki_int/ca_chain > /tmp/ca_chain.pem
2) Policies and AppRole for automatic issuance
We use AppRole so the Vault Agent can authenticate and issue certificates without human intervention. Each component (Nginx and Node) gets its own AppRole; both use the same minimal policy to issue via the "svc" role.
Keep role_id and secret_id out of version control and with file permissions set to 0600. If a secret_id leaks, revoke it immediately.
# Policy mínima para emissão pela role "svc"
sudo install -d -m 0750 -o root -g root /etc/vault
cat <<'HCL' | sudo tee /etc/vault/pki-svc.hcl >/dev/null
path "pki_int/issue/svc" {
capabilities = ["update"]
}
path "pki_int/ca_chain" {
capabilities = ["read"]
}
HCL
# Crie a policy e habilite AppRole
authEnable=$(vault auth list -format=table | grep approle || true)
[ -z "$authEnable" ] && vault auth enable approle || true
vault policy write pki-svc /etc/vault/pki-svc.hcl
# Crie AppRoles (nginx e node) com a policy
vault write auth/approle/role/nginx token_policies="pki-svc" \
secret_id_ttl=0 token_ttl=24h token_max_ttl=720h token_num_uses=0
vault write auth/approle/role/node token_policies="pki-svc" \
secret_id_ttl=0 token_ttl=24h token_max_ttl=720h token_num_uses=0
# Capture role_id e secret_id em arquivos locais protegidos
vault read -field=role_id auth/approle/role/nginx/role-id | sudo tee /etc/vault/nginx-role-id >/dev/null
vault write -field=secret_id -f auth/approle/role/nginx/secret-id | sudo tee /etc/vault/nginx-secret-id >/dev/null
vault read -field=role_id auth/approle/role/node/role-id | sudo tee /etc/vault/node-role-id >/dev/null
vault write -field=secret_id -f auth/approle/role/node/secret-id | sudo tee /etc/vault/node-secret-id >/dev/null
sudo chmod 600 /etc/vault/*-role-id /etc/vault/*-secret-id
3) Vault Agent for Nginx and the Node.js app
The Vault Agent performs auto_auth via AppRole and renders keys and certs on disk, renewing them before expiration. Define destinations, file permissions and a reload command to run when files change.
Ensure only the Nginx and Node processes can read their private keys (0600). Never leave .key files readable by other users.
# Diretórios de certificados
sudo install -d -m 0750 -o root -g www-data /etc/nginx/certs
sudo install -d -m 0750 -o root -g node /etc/node/certs || true
# Config do Vault Agent para o Nginx
cat <<'HCL' | sudo tee /etc/vault/agent-nginx.hcl >/dev/null
vault {
address = "http://127.0.0.1:8200"
}
auto_auth {
method "approle" {
mount_path = "auth/approle"
config = {
role_id_file_path = "/etc/vault/nginx-role-id"
secret_id_file_path = "/etc/vault/nginx-secret-id"
}
}
sink "file" { config = { path = "/etc/vault/nginx-token" } }
}
cache { use_auto_auth_token = true }
template {
destination = "/etc/nginx/certs/nginx.key"
perms = 0600
contents = <<EOT
{{- with secret "pki_int/issue/svc" "common_name=nginx.local" "ttl=720h" -}}
{{ .Data.private_key }}
{{- end -}}
EOT
}
template {
destination = "/etc/nginx/certs/nginx.crt"
perms = 0644
contents = <<EOT
{{- with secret "pki_int/issue/svc" "common_name=nginx.local" "ttl=720h" -}}
{{ .Data.certificate }}
{{ range .Data.ca_chain }}{{ . }}
{{ end }}
{{- end -}}
EOT
}
template {
destination = "/etc/nginx/certs/ca.pem"
perms = 0644
contents = <<EOT
{{- with secret "pki_int/ca_chain" -}}
{{ .Data.certificate }}
{{- end -}}
EOT
}
HCL
# Config do Vault Agent para o Node.js
cat <<'HCL' | sudo tee /etc/vault/agent-node.hcl >/dev/null
vault {
address = "http://127.0.0.1:8200"
}
auto_auth {
method "approle" {
mount_path = "auth/approle"
config = {
role_id_file_path = "/etc/vault/node-role-id"
secret_id_file_path = "/etc/vault/node-secret-id"
}
}
sink "file" { config = { path = "/etc/vault/node-token" } }
}
cache { use_auto_auth_token = true }
template {
destination = "/etc/node/certs/server.key"
perms = 0600
contents = <<EOT
{{- with secret "pki_int/issue/svc" "common_name=node.local" "ttl=720h" -}}
{{ .Data.private_key }}
{{- end -}}
EOT
}
template {
destination = "/etc/node/certs/server.crt"
perms = 0644
contents = <<EOT
{{- with secret "pki_int/issue/svc" "common_name=node.local" "ttl=720h" -}}
{{ .Data.certificate }}
{{ range .Data.ca_chain }}{{ . }}
{{ end }}
{{- end -}}
EOT
}
template {
destination = "/etc/node/certs/ca.pem"
perms = 0644
contents = <<EOT
{{- with secret "pki_int/ca_chain" -}}
{{ .Data.certificate }}
{{- end -}}
EOT
}
HCL
# Inicie os agentes (em segundo plano)
nohup vault agent -config=/etc/vault/agent-nginx.hcl >/tmp/agent-nginx.log 2>&1 &
nohup vault agent -config=/etc/vault/agent-node.hcl >/tmp/agent-node.log 2>&1 &
4) Node.js code that requires mTLS from Nginx
The Node.js server exposes HTTPS on port 8443 and requires a client certificate. It trusts the CA from your intermediate PKI. Adjust paths if you used different directories.
In production, run your app as a service and handle reloads when keys rotate. Some runtimes support graceful reloads without dropping connections.
// server.js (Node 18+)
const fs = require('fs');
const https = require('https');
const options = {
key: fs.readFileSync('/etc/node/certs/server.key'),
cert: fs.readFileSync('/etc/node/certs/server.crt'),
ca: fs.readFileSync('/etc/node/certs/ca.pem'),
requestCert: true,
rejectUnauthorized: true
};
https.createServer(options, (req, res) => {
if (!req.client.authorized) {
res.writeHead(401);
return res.end('Client cert inválido');
}
const peer = req.socket.getPeerCertificate();
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`Olá, mTLS. CN do cliente: ${peer.subject.CN}`);
}).listen(8443, () => {
console.log('Node.js mTLS escutando em https://127.0.0.1:8443');
});
5) Nginx as a reverse proxy with mTLS to the upstream
Nginx will act as the TLS client for the upstream Node.js server. It will present its client certificate and verify the server certificate. Use SNI to match the Node certificate CN/SAN (node.local).
For public HTTPS at the edge, integrate ACME/Let’s Encrypt afterwards.
# /etc/nginx/conf.d/node-mtls.conf
upstream node_upstream {
server 127.0.0.1:8443;
}
server {
listen 80;
server_name _;
location / {
proxy_pass https://node_upstream;
# mTLS no upstream
proxy_ssl_name node.local; # SNI para casar com o cert do Node
proxy_ssl_server_name on;
proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
proxy_ssl_trusted_certificate /etc/nginx/certs/ca.pem; # CA que assinou o cert do Node
# Certificado de cliente apresentado pelo Nginx ao Node
proxy_ssl_certificate /etc/nginx/certs/nginx.crt;
proxy_ssl_certificate_key /etc/nginx/certs/nginx.key;
# Cabeçalhos usuais
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
6) How to confirm it worked and troubleshooting
After following this guide, verify the presence of the generated files. Start the Node server and reload Nginx. Then test the connection directly with openssl and through Nginx with curl. Also check certificate expiration dates to confirm automatic renewal.
Common problems and fixes:
- x509: unknown authority — check proxy_ssl_trusted_certificate and the CA configured in Node.
- SNI/CN mismatch — align proxy_ssl_name with the CN/SAN issued for the Node.
- EACCES on key files — adjust ownership/group and permissions to 0600/0644.
- Invalid AppRole — regenerate the secret_id and verify role_id/secret_id on the agents.
- Clock skew — sync NTP to avoid incorrect NotBefore/NotAfter values.
# 1) Confira os arquivos foram emitidos
ls -l /etc/nginx/certs /etc/node/certs
# 2) Suba o servidor Node.js
node /path/para/server.js &> /tmp/node.log &
# 3) Teste mTLS direto contra o Node (apresentando o cert do Nginx)
openssl s_client -connect 127.0.0.1:8443 \
-cert /etc/nginx/certs/nginx.crt \
-key /etc/nginx/certs/nginx.key \
-CAfile /etc/nginx/certs/ca.pem -quiet <<< "GET / HTTP/1.1\nHost: node.local\n\n"
# 4) Valide a config do Nginx e recarregue
sudo nginx -t && sudo systemctl reload nginx
# 5) Teste via Nginx (HTTP -> Nginx -> mTLS -> Node)
curl -i http://127.0.0.1/
# 6) Cheque a expiração do cert do Node (renovação automática)
openssl x509 -in /etc/node/certs/server.crt -noout -enddate
Frequently asked questions
Can I use Vault dev mode in production?
No. Dev mode is only for lab use: it runs in memory, is unsealed and uses a static token. In production, initialize Vault with persistent storage, enable TLS for Vault access and perform secure unseal with multiple keys.
How do I combine internal mTLS with public HTTPS for clients?
Use mTLS only for the internal Nginx→Node link, and terminate public HTTPS at the Nginx edge using ACME/Let’s Encrypt certificates for external traffic.
How can I restrict which domains the Vault role may issue?
The example uses allow_any_name for simplicity. In real environments, set allowed_domains and allow_subdomains and disable allow_any_name to prevent issuing certificates for names outside your internal domain.
How do I rotate certificates without downtime?
With Vault Agent writing the renewed files, reload Nginx (nginx -s reload) to pick up new material. For Node.js, run it as a service with graceful reload support or restart it behind Nginx to avoid interruptions.
Can I replace AppRole with another auto_auth method?
Yes. You can use Kubernetes, AWS IAM, JWT/OIDC or other methods depending on your environment. Always grant the minimal capabilities (issue on the specific role and read the CA chain) to follow least privilege.
Need to adapt this mTLS setup to your environment?
If you want a review of your PKI, Vault policies and renewal automation for production, describe your setup and we can evaluate the next steps together.
Talk about mTLS and VaultSee also
- Tutorial: Implement Canary releases with Traefik in Docker Compose using headers and weighted routing for gradual PHP deployments
- Tutorial: Configure HAProxy with Let's Encrypt and health checks to load balance traffic between Docker containers on a VPS (step-by-step)
- Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian