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.
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.20The 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.
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 2The 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.pemand use that. The names must cover all three hosts of every project. A count of1means 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.Raise the box with the certificate
grim server:dev:setuptakes--certand--keytogether 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.Put a project on it
grim server:dev:add-projectreads 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.comThe project's
grim.jsonon the box gets"tls": "custom"in itsdeploymentblock, 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.20from 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.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.pemThe 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
wsssocket to the Vite host, because a socket has no warning to click.Ask the box what it serves
Look from a developer's machine, not from the box.
curlfails 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 -enddateThe second line shows which certificate answers for a name. A subject of
TRAEFIK DEFAULT CERTmeans none of yours matched that name, or the key could not be loaded.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 -enddateCheck 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 filesOn 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'Spells used here
grim server:dev:setup
Raise a shared dev box
Turn a bare VPS or LAN machine into a shared development server with Docker, Traefik, one dev user and grim itself.
grim server:dev:add-project
Settle a project on the box
Clone a repository onto a shared dev box, give it a dev .env and domains, and start its stack there.
grim server:dev:reload
Recreate a dev stack
Recreate the containers of a project on a dev box from your own machine, so a changed .env or grim.json takes effect.
grim server:ghcr-login
Let a server pull images
Log a server's deploy user in to the GitHub Container Registry, so the server can pull the project's private images.
Related recipes
Set up a dev box for a client
Raise a shared development server, give it a registry token, put the first project on it, let developers in by key, hand over the access card, and later take a developer or a project off again.
Work on a client's dev box
Send your public key to the operator, paste the SSH block they send back, attach your editor to the project on the box, reload the stack after a change to .env, and open the database from there.