A complete local HTTPS setup from scratch. You will generate your own CA, sign a certificate, and serve a secure app over Docker.
Browser → https://localhost:443 → Nginx (SSL) → http://app:3000 → Node.js
ssl-demo/
├── app/
│ ├── index.js ← Simple Node.js web app (plain HTTP, port 3000)
│ └── Dockerfile
├── nginx/
│ └── nginx.conf ← SSL termination + proxy to app
├── certs/ ← Generated by generate-certs.sh (do not commit)
├── generate-certs.sh ← Creates local CA + server certificate
├── docker-compose.yml ← Wires everything together
└── README.md
[Your local Root CA]
ca.key → used to sign
ca.crt → you trust this in your browser
[Server certificate]
server.key → private key (nginx holds this)
server.crt → signed by ca.crt (nginx sends this to browser)
The browser trusts ca.crt → verifies server.crt → padlock appears.
This is exactly how DigiCert/Let's Encrypt works, just with your own CA.
chmod +x generate-certs.sh
./generate-certs.shThis creates 6 files in ./certs/:
| File | What it is |
|---|---|
ca.key |
Root CA private key — keep secret |
ca.crt |
Root CA certificate — import this into your browser |
server.key |
Server private key — used by nginx |
server.csr |
Certificate signing request |
server.crt |
Server certificate — signed by your CA |
server.ext |
SAN extensions (required by modern browsers) |
You need to tell your browser to trust ca.crt.
This replaces what normally happens when DigiCert gets added to Chrome's trust store.
- Open
chrome://settings/certificates - Go to the Authorities tab → click Import
- Select
certs/ca.crt - Check "Trust this certificate for identifying websites"
- Click OK
- Open
about:preferences#privacy - Scroll to Certificates → click View Certificates
- Go to the Authorities tab → click Import
- Select
certs/ca.crt - Check "Trust this CA to identify websites"
sudo cp certs/ca.crt /usr/local/share/ca-certificates/localdev-ca.crt
sudo update-ca-certificatesdocker compose up --buildYou will see:
ssl-demo-app | App running on port 3000 (internal)
ssl-demo-nginx | ... nginx started
https://localhost
You should see the padlock icon and the SSL Demo App page.
If you see a warning instead: your CA was not imported correctly. Repeat Step 2.
In Chrome, click the padlock → Connection is secure → Certificate is valid
You will see:
- Issued to: localhost
- Issued by: LocalDev Root CA
- Valid from / to: 1 year
This is your certificate — the one generate-certs.sh created.
Browser connects to port 443
│
▼
nginx receives request
nginx reads server.crt (sends to browser as proof of identity)
nginx reads server.key (uses to complete TLS handshake)
│
Browser checks server.crt:
- Domain = localhost? ✓
- Not expired? ✓
- Signed by a trusted CA? ✓ (because you imported ca.crt)
│
TLS handshake complete — session key established
│
nginx decrypts request → proxies to http://app:3000
│
Node.js handles plain HTTP and returns the HTML page
│
nginx encrypts response → sends to browser
│
🔒 Padlock appears
# Check the certificate details
openssl x509 -in certs/server.crt -text -noout
# Verify the chain: server.crt was signed by ca.crt
openssl verify -CAfile certs/ca.crt certs/server.crt
# Test the live HTTPS connection
curl -v --cacert certs/ca.crt https://localhost
# See the full TLS handshake details
openssl s_client -connect localhost:443 -CAfile certs/ca.crtdocker compose down# Remove containers and network
docker compose down
# Remove generated certificates (to start fresh)
rm -rf certs/