Skip to content

Liber Tertius · Chapter 6 · Shared dev boxes

LAN dev box with a custom certificate

Let's Encrypt cannot reach a box that has no public name, so the certificate comes from you. grim uploads the files as they are and checks nothing about them on this path. The checking is the first step, and it is yours.

≈25 min 6 steps A PEM certificate with its key Internal DNS you can edit The root certificate of your authority
Source

Before you start

This is Set up a dev box for a client with one difference: how the box gets its certificate. Read that recipe for the tokens, the keys and the access card. Only what changes on an office network is here.

Decide on the certificate mode before the first run. A box that was raised with --email keeps its Let's Encrypt setup when you later pass a certificate, and the way back is by hand.

The box serves one certificate for every project on it. A wildcard covers one level of names and no more: *.corp.example.com matches shop.corp.example.com and does not match vite.shop.corp.example.com. The default Vite and Mailpit names sit one level deeper, so this recipe gives them flat names that the same wildcard covers.

Your internal DNS resolves all three names to the box, and your own machine uses that DNS.

$ dig +short shop.corp.example.com vite-shop.corp.example.com mail-shop.corp.example.com
10.0.0.20
10.0.0.20
10.0.0.20

The ritual, in order

Six steps. The first changes nothing and catches most of what goes wrong later. The last is the one you repeat, once per certificate lifetime.

  1. Check the certificate by hand

    For a dev box grim only checks that both files exist and can be read. A key that belongs to another certificate, a missing name or a passphrase all upload without complaint, and show up later as a browser warning with no error anywhere. Four questions, four commands.

    $ openssl x509 -in ~/certs/dev.pem -noout -pubkey | openssl sha256
    $ openssl pkey -in ~/certs/dev-key.pem -pubout | openssl sha256
    $ openssl x509 -in ~/certs/dev.pem -noout -text | grep -A1 "Subject Alternative Name"
                    DNS:*.corp.example.com, DNS:corp.example.com
    $ openssl x509 -in ~/certs/dev.pem -noout -enddate
    $ grep -c "BEGIN CERTIFICATE" ~/certs/dev.pem
    2

    The first two lines must print the same hash, or the key is not the key of this certificate. If the second one asks for a passphrase, the key is encrypted and Traefik cannot load it. Write an open copy with openssl pkey -in dev-key.pem -out dev-key-open.pem and use that. The names must cover all three hosts of every project. A count of 1 means the file holds the leaf alone. Append the intermediate certificates of your authority to it, leaf first, so that clients who trust only the root can build the chain.

  2. Raise the box with the certificate

    grim server:dev:setup takes --cert and --key together in place of --email. It uploads both files, locks the key down and starts Traefik with a file provider instead of Let's Encrypt. The host is the LAN address. The alias is only a label.

    $ grim server:dev:setup 10.0.0.20 --alias=dev-lan --cert=~/certs/dev.pem --key=~/certs/dev-key.pem --shared-pat="$(cat ~/secrets/dev-lan-team-token)"
    
    Setting up dev VPS: dev-lan
    
      ✓ [8/12] Setting up Traefik (80/443 + custom cert)

    The mode is remembered with the server on your machine, and every project you add inherits it. The box still needs to reach GitHub and GHCR from inside the network. Log it in to the registry with grim server:ghcr-login --host=dev-lan, as on any dev box.

  3. Put a project on it

    grim server:dev:add-project reads the mode from the server, so no TLS flag is needed. The two extra flags give Vite and Mailpit the flat names from Before you start.

    $ grim server:dev:add-project shop --host=dev-lan --repo=acme/shop --domain=shop.corp.example.com --vite-host=vite-shop.corp.example.com --mailpit-host=mail-shop.corp.example.com

    The project's grim.json on the box gets "tls": "custom" in its deployment block, and the routers are created without a certificate resolver. Run this command once per project, for the reasons the main recipe gives.

    DNS records do not yet point to 10.0.0.20 from a laptop on the guest network or a VPN is a false alarm. The check resolves the names where you sit, not on the box. It stops nothing, and the office DNS may be fine.

  4. Trust the authority on every developer machine

    The box presents the chain, and the browser still has to know the root. Send developers the root certificate of your authority, never a key. Each installs it once, with the line for their system.

    $ sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain corp-root.pem
    $ sudo cp corp-root.pem /usr/local/share/ca-certificates/corp-root.crt && sudo update-ca-certificates
    $ certutil -addstore -f Root corp-root.pem

    The lines are for macOS, Debian or Ubuntu, and Windows in an administrator prompt. Machines joined to a domain usually have the root already, by policy. Firefox keeps its own store unless it is told to use the system's. Hot reload needs this step more than the app does: a page you clicked through with a warning still cannot open a wss socket to the Vite host, because a socket has no warning to click.

  5. Ask the box what it serves

    Look from a developer's machine, not from the box. curl fails on an untrusted chain, which makes it the test for the step before.

    $ curl -I https://shop.corp.example.com/api/v1/healthcheck
    $ openssl s_client -connect 10.0.0.20:443 -servername vite-shop.corp.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject -enddate

    The second line shows which certificate answers for a name. A subject of TRAEFIK DEFAULT CERT means none of yours matched that name, or the key could not be loaded.

  6. Renew the certificate

    Run setup again with the new files. The upload comes before every check of what already runs, Traefik watches its configuration directory, and the new certificate is served without a restart. No project is touched.

    $ grim server:dev:setup 10.0.0.20 --alias=dev-lan --cert=~/certs/dev-2027.pem --key=~/certs/dev-2027-key.pem --shared-pat="$(cat ~/secrets/dev-lan-team-token)"
    $ openssl s_client -connect 10.0.0.20:443 -servername shop.corp.example.com </dev/null 2>/dev/null | openssl x509 -noout -enddate

    Check the new files as in step one first. The date on the second line is the proof. Put a reminder in a calendar a few weeks before it. Nothing on the box warns about an expiring certificate.

What you have now

A dev box like any other, with the certificate as two files that you own.

/opt/traefik/
  docker-compose.yml     Traefik with a file provider, no Let's Encrypt
  certs/dev.pem          your chain, replaced at every renewal
  certs/dev-key.pem      mode 0600
  dynamic/tls.yml        names the two files

On your machine the server carries "cert_mode": "custom" in ~/.grim/servers.json. Developers work on it as Work on a client's dev box describes. The only extra on their side is step four.

When it does not work

“--cert and --key must be provided together.”

One of the two flags is missing. --cert file not found or not readable: and its --key twin name a path that does not exist. Both stop the run before the server is touched.

The browser shows “TRAEFIK DEFAULT CERT”

Traefik has no certificate of yours for that name. Either the name is outside what the certificate covers, usually a vite. name one level too deep for the wildcard, or the key is encrypted or belongs to another certificate. Step one finds all three. Fix the files and run step six.

The certificate is right and the browser still warns

The machine does not trust the authority, or the file on the box holds the leaf without its intermediates. curl -v names the reason. unable to get local issuer certificate with the root installed means the chain is incomplete. Rebuild the file, leaf first, and renew.

The box was first raised with --email

Setup uploaded your files, reported Traefik as already running and left its Let's Encrypt configuration in place. Take Traefik down and delete its compose file with the login you bootstrapped with, then run setup again. Every project on the box is unreachable for that minute, and their stacks keep running.

$ ssh root@10.0.0.20 'cd /opt/traefik && docker compose down && rm docker-compose.yml'

Projects added before the switch still ask for Let's Encrypt. On the box, add "tls": "custom" to the deployment block of their grim.json and run grim reload in the project directory.

The new certificate is not served after a renewal

Give the watcher a moment, then ask again as in step five. When the old date stays, restart Traefik. Open pages reconnect on their own.

$ ssh root@10.0.0.20 'cd /opt/traefik && docker compose restart'