Skip to content

Serve trusted HTTPS from a Go program

By the end of this you'll have a Go server answering on https://localhost:8443 with a certificate your browser accepts — no warning page, no clicking through, no mkcert install. Allow about ten minutes.

Before you start

You'll need:

  • Go 1.26.5 or later, and a module to work in.
  • A machine you can install a trust anchor on. This tutorial installs a certificate authority into your system trust store, which needs an administrator password once. On Linux and macOS that's a sudo prompt in the terminal you run from.
  • A terminal. The install can't prompt for a password without one. If you're on Windows, the certificate generation works but the trust install does not — read platform support before going further.

Everything here is reversible. The last step undoes it.

What you're about to install

localca mints a certificate authority that exists only on this machine, and installs it into your trust stores. From then on, anything it signs is trusted here — and nowhere else. The key never leaves the machine, and the whole thing can be removed again.

That's a real change to your machine's trust configuration, so it's worth knowing before the password prompt appears rather than after. The trust model explains what you're trading and why it's safe done this way.

Add the module

go get gitlab.com/phpboyscout/go/localca

Write the server

Create main.go. Pick a DataDir that's stable — a hidden directory under your home is the normal choice. Don't use a temporary directory: a new one means a new authority and a new password prompt every run.

package main

import (
    "context"
    "log"
    "log/slog"
    "net/http"
    "os"
    "path/filepath"

    "gitlab.com/phpboyscout/go/localca"
)

func main() {
    home, err := os.UserHomeDir()
    if err != nil {
        log.Fatal(err)
    }

    cfg := localca.Config{
        DataDir: filepath.Join(home, ".myapp"), // root + leaf live here; key files are 0600
        AppName: "myapp",                       // names the root: "myapp local CA (you@host)"
    }

    // First run: mint the authority, install it (one password prompt), and return a
    // certificate ready to serve. Later runs reuse both and prompt for nothing.
    pair, err := localca.EnsureServed(context.Background(), cfg,
        []string{"localhost", "127.0.0.1"},
        localca.WithLogger(slog.Default()), // without this, localca says nothing at all
    )
    if err != nil {
        log.Fatal(err)
    }

    tlsCfg, err := pair.ServerConfig()
    if err != nil {
        log.Fatal(err)
    }

    http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
        _, _ = w.Write([]byte("trusted HTTPS, no warning\n"))
    })

    srv := &http.Server{Addr: ":8443", TLSConfig: tlsCfg}
    log.Fatal(srv.ListenAndServeTLS("", "")) // certificates are already in TLSConfig
}

Passing the logger is optional but worth doing here: it's what prints the line explaining why you're about to be asked for a password.

Run it and answer the prompt

go run .

You'll see the explanation, then the password prompt:

level=INFO msg="installing a local development CA into the trust store so this tool can
serve HTTPS locally; reverse with Uninstall" common_name="myapp local CA (you@host)"
[sudo] password for you:

Type your password. That prompt is the consent gate — nothing is installed into a system trust store without it. Afterwards the server starts and stays quiet.

If you get ErrElevationUnavailable instead, localca decided it couldn't obtain the privileges — usually no sudo, or standard input isn't a terminal. Troubleshooting covers the ways round it.

Check it worked

From another terminal:

curl https://localhost:8443/
trusted HTTPS, no warning

That's the meaningful result: plain curl, no -k, no --cacert. It's verifying against your system trust store and the certificate passes.

Open https://localhost:8443/ in Chrome, Safari or Edge and you'll get the same — a padlock and no interstitial. Firefox is the exception. It keeps its own certificate database and only trusts the root if certutil was available when you installed. If Firefox warns and other browsers don't, that's what happened, and the fix is two commands.

Look at what was created

ls -l ~/.myapp
-rw-r--r--  leaf.pem
-rw-------  leaf-key.pem
-rw-r--r--  rootCA.pem
-rw-------  rootCA-key.pem
-rw-r--r--  trust-install.json

Five files, and their permissions matter. rootCA-key.pem is the signing key for an authority your machine trusts, so it's 0600 and it must not leave this machine — copying it somewhere else hands whoever has it the ability to mint a certificate for any site your machine will accept. rootCA.pem is the public half and is safe to share; that's the one you'd import onto a phone to make it trust your development server too.

Data directory layout covers what each file is for.

Stop the server and run it again. No prompt, no new files — the root is already trusted and the cached leaf is still valid, so EnsureServed hands back the same pair and does nothing else.

Take it back off

When you're done, remove the authority from your trust stores and delete its key:

a, err := localca.New(cfg)
if err != nil {
    log.Fatal(err)
}

if err := a.Uninstall(context.Background(), localca.Purge()); err != nil {
    log.Fatal(err)
}

This prompts for a password again, because removing from the system store needs the same privileges as adding to it.

One thing Purge() doesn't do is delete the cached leaf, which is still signed by the root you just removed. Leave it behind and the next EnsureServed in this directory reuses it under a fresh root, and nothing trusts the result. Tidy it up yourself:

rm -f ~/.myapp/leaf.pem ~/.myapp/leaf-key.pem

Where to go next