1. Why I built my own CA
When you open an HTTPS site, the browser shows whether the connection is secure. It decides this by checking who signed the site's certificate. Operating systems and browsers keep a store of trusted root Certificate Authorities (CAs), such as the roots behind Let's Encrypt or DigiCert. If the site's certificate chains back to one of those roots, the connection is trusted.
I wanted to see this from the inside. So I created my own Root CA, used it to sign a certificate for a test site called mytestsite.local, and made both Chrome and curl trust it.
My setup: a Windows laptop, the server running in WSL2 (Ubuntu 20.04, OpenSSL 1.1.1f), and Chrome on Windows.
Tip on the hostname: I used
mytestsite.local, and it worked fine on my machine. If you repeat this, considermytestsite.testinstead..localis reserved for multicast DNS (mDNS) and can cause name-resolution conflicts on some networks, while.testis reserved for testing (RFC 6761). Just replace the name in every command.
2. How HTTPS trust works
My Local Root CA (self-signed, CA:TRUE) <- added to the trust store │ signs ▼ mytestsite.local (CA:FALSE, SAN=mytestsite.local) <- sent by the server When the browser connects, the server sends its certificate. The browser checks:
- Chain: does the certificate chain up to a root in my trust store?
- Name: does the certificate's Subject Alternative Name (SAN) match the hostname in the URL?
- Dates: is the certificate within its validity period?
If any check fails, you get the "Your connection is not private" page.
Real websites usually have one more link in the chain. The root CA signs an intermediate CA, and the intermediate signs the website certificate. The root key stays offline, so it is rarely exposed. For a local lab, signing directly with the root keeps things simple.
3. Create the Root CA
All files go in one lab folder. Certificates live in certs/, and the server will run from the folder above it:
mkdir -p ~/local-https/certs && cd ~/local-https/certs First, a small config file that defines the CA's identity and extensions:
cat > ca.cnf <<'EOF' [req] distinguished_name = dn x509_extensions = v3_ca prompt = no [dn] CN = My Local Root CA [v3_ca] basicConstraints = critical, CA:TRUE keyUsage = critical, keyCertSign, cRLSign subjectKeyIdentifier = hash EOF Then generate the key and the self-signed root certificate:
openssl genrsa -out myRootCA.key 2048 openssl req -x509 -new -key myRootCA.key -sha256 -days 3650 \ -out myRootCA.crt -config ca.cnf What each part does:
-
basicConstraints = CA:TRUEmarks this certificate as a Certificate Authority. -
keyCertSignallows it to sign other certificates.cRLSignallows it to sign Certificate Revocation Lists. -
-x509makes the certificate self-signed. A root signs itself because nothing sits above it. -
openssl genrsawrites the private key without passphrase encryption by default. That's acceptable in a controlled local lab, but protect the file carefully: anyone who obtains it can issue certificates using your CA.
Check the result:
openssl x509 -in myRootCA.crt -noout -text | grep -A1 -E "Basic Constraints|Key Usage" # X509v3 Basic Constraints: critical # CA:TRUE # X509v3 Key Usage: critical # Certificate Sign, CRL Sign What went wrong on my first try: I added the CA extensions with -addext "basicConstraints=critical,CA:TRUE" on the command line. On OpenSSL 1.1.1, the default openssl.cnf already adds basicConstraints, so my root certificate ended up with that extension twice. Signing still worked, but openssl verify failed with "unable to get local issuer certificate". A dedicated config file avoids the duplicate and states every extension explicitly.
4. Generate and sign the server certificate
# Private key + Certificate Signing Request (CSR) # -nodes matters here: this command creates a new key and would otherwise ask for a passphrase openssl req -new -nodes -newkey rsa:2048 -keyout mytestsite.local.key \ -out mytestsite.local.csr -subj "/CN=mytestsite.local" # Extensions for the server certificate cat > v3.ext <<EOF authorityKeyIdentifier=keyid,issuer basicConstraints=CA:FALSE keyUsage=digitalSignature,keyEncipherment extendedKeyUsage=serverAuth subjectAltName=@alt_names [alt_names] DNS.1=mytestsite.local EOF # The CA signs the CSR openssl x509 -req -in mytestsite.local.csr -CA myRootCA.crt -CAkey myRootCA.key \ -CAcreateserial -out mytestsite.local.crt -days 825 -sha256 -extfile v3.ext Verify the chain and the hostname:
openssl verify -CAfile myRootCA.crt -verify_hostname mytestsite.local mytestsite.local.crt # mytestsite.local.crt: OK openssl verify -CAfile myRootCA.crt -verify_hostname wrong.local mytestsite.local.crt # error 62 at 0 depth lookup: Hostname mismatch 5. Configure the HTTPS server
Save this as ~/local-https/server.py, one level above certs/:
import http.server, ssl class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): body = b"<h1>Hello over trusted HTTPS</h1>" self.send_response(200) self.send_header("Content-Type", "text/html") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain("certs/mytestsite.local.crt", "certs/mytestsite.local.key") httpd = http.server.HTTPServer(("127.0.0.1", 443), Handler) httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) httpd.serve_forever() cd ~/local-https sudo python3 server.py # ports below 1024 need root 6. Configure local hostname resolution
mytestsite.local has no DNS record, so I mapped it in the Windows hosts file (C:\Windows\System32\drivers\etc\hosts):
127.0.0.1 mytestsite.local With the default WSL2 networking mode, Windows can reach WSL services on localhost, so Chrome on Windows reaches the Python server running in WSL.
Inside WSL, curl needs to resolve the name too. Check it first:
getent hosts mytestsite.local # 127.0.0.1 mytestsite.local WSL generates its own /etc/hosts (the generateHosts setting in /etc/wsl.conf, enabled by default, Microsoft Learn), and that generated file doesn't include custom Windows entries. On my machine, mytestsite.local still resolved inside WSL even though it wasn't in WSL's /etc/hosts, so WSL's DNS forwarding may have resolved it through Windows. This behavior can depend on the WSL version and networking configuration, so don't rely on it. If getent prints nothing, add the mapping inside WSL as well:
echo "127.0.0.1 mytestsite.local" | sudo tee -a /etc/hosts With generateHosts enabled, WSL can rewrite /etc/hosts when it restarts. If the entry disappears, add it again or set generateHosts = false.
7. Configure Windows certificate trust
Windows and WSL each have their own trust store, and each trusts only what you add to it.
First, copy the root certificate (only the .crt, never the .key) somewhere Windows can reach it. WSL mounts the C: drive at /mnt/c:
cp ~/local-https/certs/myRootCA.crt /mnt/c/Users/Public/myRootCA.crt Then import it into the Windows Trusted Root Certification Authorities store. Run PowerShell as Administrator:
Import-Certificate -FilePath "C:\Users\Public\myRootCA.crt" -CertStoreLocation Cert:\LocalMachine\Root Chrome and Edge use this store. Firefox uses its own store by default.
8. Configure WSL certificate trust
Windows now trusted my CA, but curl inside WSL still failed:
curl: (60) SSL certificate problem: unable to get local issuer certificate Ubuntu doesn't read the Windows store. To add the CA to Ubuntu's store:
sudo cp ~/local-https/certs/myRootCA.crt /usr/local/share/ca-certificates/myRootCA.crt sudo update-ca-certificates # 1 added, 0 removed; done. The file must end in .crt, or update-ca-certificates skips it. The command also printed rehash: warning: skipping ca-certificates.crt. That warning isn't a problem: the file is the combined bundle the tool builds, not a single certificate.
Python's requests library uses its own CA bundle (certifi) by default. To make it use the Ubuntu system bundle instead:
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt 9. Verify with curl and Chrome
curl https://mytestsite.local/ # <h1>Hello over trusted HTTPS</h1> 
curl fails before the CA is trusted in WSL, then succeeds after update-ca-certificates.
After a Chrome restart, the page loaded with no security warning:

Chrome loads the site with no security warning.
To see the chain, click the icon next to the URL, then Connection is secure → Certificate is valid. It reads My Local Root CA → mytestsite.local.
10. Clean up and security considerations
A trusted root CA is powerful. Anyone with myRootCA.key could issue a certificate for any domain, even google.com, and this laptop would accept it. So I removed the CA when I was done.
WSL:
sudo rm /usr/local/share/ca-certificates/myRootCA.crt sudo update-ca-certificates --fresh Windows (PowerShell as Administrator):
Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -eq "CN=My Local Root CA" | Remove-Item Hosts files: delete the mytestsite.local line from the Windows hosts file, and from WSL's /etc/hosts if you added it there. Also delete C:\Users\Public\myRootCA.crt.
Key files: delete myRootCA.key and the server key, or keep them in a protected location if you plan to reuse the lab.
After cleanup, the same site shows the warning again, which confirms the trust is gone:

After cleanup, Chrome marks the site "Not secure" and crosses out https. In WSL, curl https://mytestsite.local/ fails again with curl: (60) unable to get local issuer certificate.
11. What I learned
- HTTPS trust is a chain. The client trusts a root, the root (usually through an intermediate) signs the site, so the client trusts the site.
- A certificate isn't trusted on its own. Trust depends on what is in the client's trust store.
- One laptop can have several trust stores: Windows, WSL/Ubuntu, Firefox, and Python's
certifi. - Browsers match the hostname against the SAN, not the CN.
-
openssl verify -verify_hostnamechecks the chain and the name in a single command. - The root CA private key is the most sensitive file in the whole setup.
