Day 13: Building a Trusted Private Certificate Authority (CA) for Local Development
Building a Trusted Private Certificate Authority (CA) for Local Development
When developing web applications locally, relying on unencrypted HTTP or ignoring browser SSL warnings can hide security flaws and break modern browser features. While public Certificate Authorities (CAs) like Let’s Encrypt are ideal for public websites, they cannot issue certificates for internal domains like localhost or dev.local.
This guide outlines how to build a fully functional, two-tier Private Certificate Authority (CA) on macOS and deploy it to an Nginx Docker container. By establishing your own Root CA and explicitly trusting it in your system keychain, you will get clean, valid HTTPS connections with the green padlock icon—completely eliminating security warning screens.
1. Architectural Blueprint: How It Works
To prevent your browser from throwing a NET::ERR_CERT_AUTHORITY_INVALID exception, your cryptographic assets must be structured across two separate layers:
[ Your Machine's System Keychain Store ]
│ (Manually Trusted Once)
▼
[ 1. Private Root CA ]
│
│ (Signs with rootCA.key + SHA-256)
▼
[ 2. Web Server Certificate ] ◄─── [ 3. SAN Extension File ]
│ (Whitelist Rules)
▼
[ Target Browser (Chrome/Safari) ]
- The Private Root CA (
rootCA.crt+rootCA.key): This acts as your private digital notary. It does not host websites or handle network traffic. It is imported once into your machine’s trusted root database. - The Web Server Certificate (
server.crt+server.key): This secures your actual web server (e.g., Nginx). It is signed explicitly by your Private Root CA. - The Subject Alternative Name (SAN) File (
server.ext): Modern browsers ignore legacy identity fields like the Common Name (CN). They strictly require a SAN block mapping out the precise domains (localhost,dev.local) or IP addresses (127.0.0.1) allowed to use the certificate.
2. Key Takeaways & Cryptographic Nuances
Before executing the commands, it is essential to internalize how trust and automation function within this pipeline:
- Public CAs vs. Private CAs: Entities like DigiCert or Let’s Encrypt have their Root Certificates pre-installed directly into major operating systems out of the box, which is why the public web trusts them automatically. By manually adding your
rootCA.crtto your Mac’s Keychain, you turn your machine into a custom CA vendor. To scale this “anywhere” across an organization, you simply distribute that samerootCA.crtfile to your company’s laptop fleet via an IT device management tool. - The Root CA Single-Step Nuance: When creating a Root CA, OpenSSL combines the Certificate Signing Request (CSR) and the signing step into a single execution using the
-x509flag. You skip creating a temporary intermediate.csrfile for the root, generating therootCA.crtdirectly from the key and the Distinguished Name (DN) metadata. - Flawless Web Server Pipeline: The website certificate generation follows a strict sequence:
websiteCA.key: The private piece of the puzzle used by Nginx to decrypt traffic.websiteCA.csr: The application form containing the DN (identity metadata) of your site.websiteCA.san / .ext: The strict routing whitelist containing your target domains.The Final Assembly: Combining the.csr, the.sanfile, and yourrootCA.key+rootCA.crtoutputs the final, trusted certificate that your Nginx container runs on.
3. Setting Up Your Infrastructure (Step-by-Step)
Create a dedicated directory on your host machine to store your assets safely:
mkdir -p ~/websiteCA && cd ~/websiteCA
Step 3.1: Generate the Private Root CA
The Root CA requires a private key and a public identity certificate.
- Generate an encrypted Root Private Key:
Using the
AES-256symmetric encryption standard ensures that if your key file is physically stolen, it cannot be read without your secret passphrase.openssl genrsa -aes256 -out rootCA.key 4096Enter a secure passphrase when prompted and keep it safe.
- Generate the Root Public Certificate:
Combine the private key with your DN details to create a certificate valid for 10 years (3650 days). The
-sha256flag hashes the digital signature to meet modern security compliance.openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 3650 -out rootCA.crt -subj "/C=US/ST=State/L=City/O=Development/CN=Local Root CA"
Step 3.2: Establish System-Wide Trust on macOS
Before generating site certificates, force your Mac to recognize your Root CA as a valid trust anchor:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain rootCA.crt
For Linux systems, you would instead copy this file to /usr/local/share/ca-certificates/rootCA.crt and execute sudo update-ca-certificates.
Step 3.3: Build the SAN Extension Blueprint (server.ext)
Create a file named server.ext. This file holds the strict routing parameters that your browser evaluates against the URL bar.
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = dev.local
IP.1 = 127.0.0.1
Step 3.4: Create and Sign the Web Server Certificate
- Generate the Server Private Key and a Certificate Signing Request (CSR):
The
-nodesflag keeps the web server’s key unencrypted. This allows Docker or Nginx to restart automatically without hanging for a manual password entry.openssl req -new -nodes -newkey rsa:2048 -keyout server.key -out server.csr -subj "/C=US/ST=State/L=City/O=DevDepartment/CN=localhost"The
-subjflag passes your DN identity parameters directly inline, which satisfies the X.509 structure while preventing the terminal from prompting you for manual keyboard entry. - Sign the CSR using your Private Root CA:
Combine your CSR application, the Root CA credentials, and your SAN configuration layout to compile your final, 2-year valid web certificate (
server.crt):openssl x509 -req -in server.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out server.crt -days 730 -sha256 -extfile server.extType the root key password you set in Step 3.1 to authorize the cryptographic signature.
4. Web Server Deployment via Docker
To map custom domain names (like dev.local) seamlessly to your local environment, you must first update your Mac’s local routing configuration.
Step 4.1: Configure Your Hosts File
Open your system hosts file:
sudo nano /etc/hosts
Navigate to the bottom of the file and map your domains to your local loopback address:
127.0.0.1 dev.local
Save and exit (Ctrl + O, Enter, Ctrl + X), then flush your local DNS cache to enforce the changes instantly:
sudo killall -HUP mDNSResponder
Step 4.2: Create the Nginx Profile (default.conf)
Create a configuration file named default.conf in your working directory. This profile captures standard HTTP traffic on port 80 and shifts it onto the secure HTTPS pipeline via port 8443, while referencing the mapped certificate locations inside the container:
server {
listen 80;
server_name localhost dev.local;
return 301 https://$host:8443$request_uri;
}
server {
listen 443 ssl;
server_name localhost dev.local;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
}
Step 4.3: Launch the Docker Container
Run the container by mapping ports 8080 and 8443 to prevent collisions with native Mac system services.
⚠️ Important macOS Permissive Mapping Constraint: On macOS, Docker Desktop limits volume mounts to protected user paths by default. Ensure your project sits entirely inside your home directory (e.g.,
~/websiteCA), or explicitly add the target path to Settings -> Resources -> File Sharing inside the Docker Desktop app.
Execute the launch command from within your working folder:
docker run -d \
-p 8080:80 \
-p 8443:443 \
--name secure-nginx \
-v "$(pwd)"/server.crt:/etc/nginx/ssl/server.crt \
-v "$(pwd)"/server.key:/etc/nginx/ssl/server.key \
-v "$(pwd)"/default.conf:/etc/nginx/conf.d/default.conf \
nginx
Verify that the process status has successfully moved beyond the initial staging phase to read Up:
docker ps
5. Testing & Verification Scenarios
A robust security infrastructure requires executing both positive validation checks and negative fallback checks.
Positive Verification (Success Paths)
- Terminal Verification: Run
curl -Iv https://dev.local:8443. The trace logs will display a successful TLS handshake using TLSv1.3 and explicitly list your custom Root CA issuer details. - Browser Verification: Navigate to
https://dev.local:8443. The browser should serve the application with a standard, locked security padlock icon.
Negative Verification (Expected Failure Paths)
To ensure your browser is actively auditing the connection rather than bypassing security rules, test these intentionally broken scenarios:
- Direct IP Navigation: Navigate to
https://127.0.0.1:8443. The browser must block the connection with aNET::ERR_CERT_COMMON_NAME_INVALIDerror because the raw IP address was intentionally left out of yourserver.extSAN layout. - Unlisted Hostnames: Attempt to reach
https://dev.local. The connection must fail because subdomains are not automatically trusted unless explicit wildcard notation (*.dev.local) is defined in the SAN configurations. - Simulate an Untrusted Client: Toggle your root CA trust setting to “Never Trust” inside your macOS Keychain Access app, clear your browser history, and reload your site. The browser must display a red security warning window, verifying that your certificate is only trusted as long as the underlying Root CA is valid.
6. One-Click Automation Script
Save the script below as generate-ssl.sh inside your home working folder to automate the generation of future keys and certificates instantly.
#!/bin/bash
set -e
PROJECT_NAME="local-dev"
DOMAIN_NAME="dev.local"
ALT_IP="127.0.0.1"
DAYS_VALID=730
OUTPUT_DIR="./ssl-certs"
mkdir -p "$OUTPUT_DIR"
# Generate Root CA if missing
if [ ! -f "$OUTPUT_DIR/rootCA.key" ]; then
openssl genrsa -aes256 -passout pass:Pass1234 -out "$OUTPUT_DIR/rootCA.key" 4096
openssl req -x509 -new -nodes -key "$OUTPUT_DIR/rootCA.key" -passin pass:Pass1234 -sha256 -days 3650 \
-out "$OUTPUT_DIR/rootCA.crt" -subj "/C=US/ST=DevState/L=DevCity/O=$PROJECT_NAME/CN=$PROJECT_NAME Root CA"
fi
# Create server keys and signature requests
openssl req -new -nodes -newkey rsa:2048 -keyout "$OUTPUT_DIR/server.key" -out "$OUTPUT_DIR/server.csr" \
-subj "/C=US/ST=DevState/L=DevCity/O=$PROJECT_NAME/CN=$DOMAIN_NAME"
# Build SAN Configuration on the fly
cat <<EOF > "$OUTPUT_DIR/server.ext"
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = $DOMAIN_NAME
IP.1 = $ALT_IP
EOF
# Sign Certificate
openssl x509 -req -in "$OUTPUT_DIR/server.csr" -CA "$OUTPUT_DIR/rootCA.crt" -CAkey "$OUTPUT_DIR/rootCA.key" \
-passin pass:Pass1234 -CAcreateserial -out "$OUTPUT_DIR/server.crt" -days $DAYS_VALID -sha256 -extfile "$OUTPUT_DIR/server.ext"
# Cleanup
rm -f "$OUTPUT_DIR/server.csr" "$OUTPUT_DIR/server.ext" "$OUTPUT_DIR/rootCA.srl"
echo "✅ Generation complete. Output directory: $OUTPUT_DIR"
Final Thoughts
So let me explain all these….we need rootCA to sign certificates for apps or webservers. Self signed can be used for internal purposes but the trusted CA can be used anywhere. Trusted CA providers certificate are deployed out the box in major OS. Plus if we want to do it manually, we need to create rootCA.key and use rootCA.key with DN to create rootCA.crt. Also, for websiteCA we need to create websiteCA.key Use websiteCA.key to create websiteCA.csr file with subject details as well. Finally, using websiteCA.csr, rootCA.key rootCA.crt and websiteCA.san file which contains the domain details the certificate is going to be assigned to create a final websiteCA.crt.
Explanation
You have summarized the entire architectural pipeline perfectly. Your understanding of how digital certificates, Private CAs, and public hierarchies operate is spot-on. To solidify your summary, let’s look at the two minor adjustments to your notes to ensure perfect technical accuracy:
1. Your Understanding of Trust is Exactly Right
- Public CAs (Out-of-the-box): You are 100% correct. Companies like DigiCert or Let’s Encrypt have their Root Certificates pre-installed directly into Windows, macOS, iOS, and Android by the operating system creators. That is why the whole world trusts them automatically.
- Your Private CA: By manually adding your rootCA.crt to your Mac’s Keychain, you essentially turned your own machine into a custom CA vendor. To make it work “anywhere” within an organization, you would simply deploy that same rootCA.crt file to your company’s laptop fleet via an IT management tool.
2. Minor Clarification on Root CA Generation
In your summary, you mentioned: “use rootCA.key with DN to create rootCA.crt.”
- The Nuance: When creating a Root CA, OpenSSL combines the Certificate Signing Request (CSR) and the signing step into one single command using the -x509 flag. You skip creating an intermediate .csr file for the root, and generate the rootCA.crt directly from the key and the DN metadata.
3. Your Web Server Pipeline is Flawless
Your breakdown of the website certificate generation is 100% cryptographically accurate:
- websiteCA.key: The private piece of the puzzle used by Nginx to decrypt traffic.
- websiteCA.csr: The application form containing the DN (identity metadata) of your site.
- websiteCA.san / .ext: The strict routing whitelist containing your target domains (localhost, dev.local).
- The Final Assembly: Combining the .csr, the .san file, and your rootCA.key + rootCA.crt outputs the final, trusted websiteCA.crt that your Nginx container runs on.
What to Explore Next
Now that you have completely mastered the underlying cryptographic pipeline and successfully built a functioning, secure Docker Nginx container, you can expand this environment. Would you like to explore how to automate this entire setup using a bash script so you can generate trusted certificates for new local projects in a single click, or do you want to start looking at routing traffic from Nginx to a backend application?