Firetower

Install on your server, with a custom domain

The domain is registered in public DNS, so browsers and Let's Encrypt can resolve it. Its A records point at a private address, so only devices on your mesh network can connect to it. The result is a real name and a real certificate, reachable from any device you have on the mesh, with no tunnel.

Not sure this is the right one? See the Overview. The server side is the same as over SSH — same control plane, same worker. What changes is the front door.

#How it gets a certificate without being reachable

This is the part people expect to be impossible, so it is worth a minute before you start.

The control plane holds every git token, every agent credential and the root key, so anyone who reaches it can erase the codebase of the company that installed it. It does not go on the internet to get a padlock.

The usual ACME challenges — HTTP-01 and TLS-ALPN-01 — work by having Let's Encrypt open a connection to you. A machine Let's Encrypt can reach is a machine anyone can reach.

DNS-01 proves control the other way round. Caddy writes a TXT record through your DNS provider's API, and the authority reads it back out of DNS. Every connection Caddy makes is outbound.

Nothing connects in. The name can resolve to a private address and answer nobody outside your network.

DNS-01 is also the only challenge that can issue a wildcard, which this needs twice over: previews are served on subdomains, and one wildcard keeps every preview hostname out of the public Certificate Transparency logs — where a hostname that is the credential for that preview does not belong.

This loses work

There is nothing to open in a firewall. Port 80 is published only to redirect to 443, and no challenge is answered on it — drop it if you would rather nothing answered there at all.

#Before you start

A domain you control

Its DNS must be hosted at a provider Caddy has a module for. Cloudflare, DigitalOcean, Hetzner, Vercel, GoDaddy, deSEC, Gandi, Linode, Vultr and DuckDNS need only an API token; ninety-six others work too.

An API token for that provider

Caddy writes the ACME challenge record with it. The prompt links the page where you create one.

Docker, Compose, Node 20+

On the machine the app will run on, as with any install.

Note

Only the DNS challenge record is written through that API — Firetower never publishes anything to the internet.

#Install Tailscale first

Required, not optional. Firetower is served on a private address, and the installer stops if the machine has no address your team can reach.

Run this onthe machine the app will run on

sh
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale ip -4

tailscale ip -4 prints the machine's tailnet address:

  100.92.14.7

That is the address the two DNS records will point at, and the one the installer should offer you in step 2. Note it down now.

Then every device that will open Firetower — your laptop, your phone — joins the same tailnet.

#Install the CLI

Run this onthe machine the app will run on

sh
npm i -g @firetower/cli
firetower install

#1. Answer Your own domain, over Tailscale or another mesh VPN

  Firetower

◆  How will people reach this Firetower?
│  ○ Only from this machine (simplest to start — directly, or with `firetower tunnel` from your laptop)
│  ● Your own domain, over Tailscale or another mesh VPN
│  ○ Behind a reverse proxy I already run
└

#2. Point your Tailscale address

Asked before the domain name. The installer reads the machine's network interfaces and offers anything that looks like a mesh VPN first:

  ✓ Mesh network detected      100.92.14.7 (tailscale0)

◆  Reach Firetower on 100.92.14.7?
│  ● Yes
│  ○ No — show me the others
└

Make sure it is the same address tailscale ip -4 printed. If it offers a different one, choose No and pick the tailnet address from the list.

#3. Then the domain name

  ◇  Domain
│  firetower.example.com

#4. Then where the certificate comes from

Ten providers that take a single API token are listed by name. Anything else is typed in as its caddy-dns module name, which is checked against all ninety-six. The last option is for a certificate you already hold.

  ◆  Where should the certificate come from?
│  ● Let's Encrypt, through Cloudflare
│  ○ Let's Encrypt, through DigitalOcean
│  ○ Let's Encrypt, through Hetzner
│  ○ …
│  ○ Let's Encrypt, through another DNS provider…
│  ○ I already have a certificate
└

It then states the requirement for issuance to work — the domain's DNS must be hosted at the provider whose token you are about to paste — links the page where you create the key, and reads the token masked:

  Firetower gets the certificate by writing a record through
Cloudflare's API, so firetower.example.com has to be hosted there. If its
DNS lives somewhere else the write succeeds and the record never appears.

Create a key:

  https://dash.cloudflare.com/profile/api-tokens

◇  API token for cloudflare
│  ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪

Providers that need more than one value — Route 53, Azure, Google Cloud and about forty others — take no token here. The module is compiled in and a block is written into your Caddyfile to fill in. Which credential each provider needs →

#5. It prints the two DNS records and waits

Still before anything is written:

  Create these DNS records in Cloudflare:

  firetower.example.com      A   100.92.14.7
  *.firetower.example.com    A   100.92.14.7

The wildcard is not optional — previews are served on subdomains, and
without it you get an interface that works and previews that do not.

◆  Done?
│  ● Yes, continue
│  ○ Not yet — stop, and I will run this again
└

Neither record is needed for the certificate. DNS-01 proves control with a TXT record at _acme-challenge, and a certificate will be issued for a name with no A record at all. The A records are what make the deployment reachable.

#6. It fetches the deployment files, then checks the machine

The same checks as any install, plus a DNS check that resolves both the domain and a random label under it. The second lookup is the only way to distinguish a wildcard record from a single subdomain record that happens to exist.

#7. No port question with this option

Caddy takes 443, and 80 for the redirect. The control plane stays behind it on 8080, published on loopback only.

#8. Directory, admin username, admin password

Where should Firetower live?

Defaults to ~/firetower, or /opt/firetower as root.

Administrator username

Defaults to admin.

Administrator password

Generate one, or type your own.

Then the plan, with the provider named and the token withheld:

  Here is what I will do:

directory     /opt/firetower
url           https://firetower.example.com
published     127.0.0.1:8080 — and Caddy on 100.92.14.7, ports 443 and 80
certificate   Let's Encrypt, over DNS-01 through cloudflare — renewed by Caddy
dns           cloudflare, with the token you gave (not shown)
admin         admin, with the password shown once below
root key      generated, written to /opt/firetower/.env

◆  Continue?
│  ● Yes / ○ No
└

Answering No writes nothing.

#9. It builds Caddy

Caddy loads DNS providers as compiled-in modules, so the first start pulls a Go toolchain and compiles a Caddy with your provider in it. This takes a few minutes, once — later starts use the cached image. The build output is streamed so you can see it progressing.

#10. It requires you to acknowledge the root key

  ──────────────────────────────────────────────────────────────
Save this. It is not stored anywhere you can read it back.

  FIRETOWER_ROOT_KEY  q1Zk8mWv2r7XcN0pT4sYbA6dJhLgE3uF9nRxKtVwQmc=

Every credential Firetower holds is sealed with it. Back it up
somewhere that is not your database backup — a stolen database
opens nothing on its own, and losing this key means adding every
credential again.
──────────────────────────────────────────────────────────────

◆  I have saved the root key
│  ○ Yes / ● No
└

This loses work

The root key is the only unrecoverable thing here. Every stored credential is sealed with it, which is why the installer requires you to acknowledge it. Back it up somewhere separate from your database backup — see Secrets.

#11. It waits for the certificate

The containers report healthy well before Let's Encrypt has answered, and opening the URL in that window returns ERR_SSL_PROTOCOL_ERROR. So the installer requests the bare name first, reloads Caddy with the wildcard, then waits for both:

  ◐  Waiting for certificates (2m14s) — *.firetower.example.com — both
   prove themselves at the same DNS record, so one often needs a retry

After five minutes the message changes to still retrying. Safe to Ctrl-C; Caddy carries on without you.

This takes seconds on Cloudflare. On a provider whose API replaces the records at a name rather than adding to them — GoDaddy does — the two challenges overwrite each other, and the second certificate is retried a minute later and succeeds on its own. This is expected on a first install there, not a failure. The wait times out after fifteen minutes and reports what is outstanding; Caddy keeps retrying either way.

#12. Done

Reachable from any device on your tailnet:

  Firetower is running.

  https://firetower.example.com

  username  admin
  password  velvet-timber-harbor-332

The admin password is printed once. You are asked to replace it when you sign in, and then to delete ADMIN_INITIAL_PASSWORD from .env.

Caddy renews the certificate automatically, at about two-thirds of its lifetime. There is nothing to run.

#What it wrote

firetower.yml

The compose file, from the release. Replaced in full by firetower upgrade.

Caddyfile

With your DNS provider in it. Yours to edit — firetower upgrade never touches this file.

.env

chmod 600. Domain, ports, URL, DNS provider and token, and the two generated secrets. An existing .env keeps every value already in it — nothing here regenerates a secret.

certs/

Empty unless you supply your own certificate. Created up front so that Docker does not create it as root later.

firetower install refuses to run in a directory that already holds a deployment. Use firetower domain to change how an existing deployment is reached, and firetower upgrade to move it to a newer release.

#Adding a domain to an install you already have

Nothing above needs a reinstall. On a deployment that is already running, firetower domain asks the same questions and changes the same values:

sh
firetower domain ft.example.com

Or with no argument at all, to be asked everything. It opens with what the deployment is today, prints the two DNS records as soon as it has the name, and shows every .env change before writing any of them — with the token masked, because that block is what people paste into an issue:

  Here is what I will do:

reached by    ft.example.com, over HTTPS
published     127.0.0.1:8080 — and Caddy on 443 and 80, for everyone else
certificate   Let's Encrypt, over DNS-01 through cloudflare — renewed by Caddy
url           https://ft.example.com

.env:

  COMPOSE_PROFILES         unset → tls
  DNS_API_TOKEN            unset → ••••••••
  DNS_PROVIDER             unset → cloudflare
  DOMAIN                   empty → ft.example.com
  FIRETOWER_PREVIEW_DOMAIN unset → ft.example.com
  FIRETOWER_PUBLIC_URL     http://localhost:8080 → https://ft.example.com
  HTTPS_PORT               unset → 443

◆  Continue?
│  ● Yes / ○ No

Answering No writes nothing. Nothing about the release moves either way: no images are pulled, no migrations run, no database is touched.

Every answer has a flag, for a scripted change:

sh
firetower domain ft.example.com \
  --dns-provider cloudflare --dns-token "$CLOUDFLARE_TOKEN"

--dns-token on its own rotates the credential and keeps the rest. --dns-provider on its own moves zones. firetower domain --none takes the domain away again and puts the deployment back on loopback — removing the Caddy container with it, which a plain docker compose up -d would not.

#If it doesn't work

sh
firetower logs caddy -f
firetower doctor

firetower doctor reports what it can see:

  ✓ domain               ft.example.com → 10.0.0.5, an address on this machine, and *.ft.example.com resolves
✓ certificate          Caddy renews it, over DNS-01 through cloudflare — none issued yet
In firetower logs caddyWhat it means
module not registered: dns.providers.xDNS_PROVIDER changed without a rebuild — firetower domain again, or up -d --build
wrong argument count ... {env.DNS_API_TOKEN}That provider takes several values; its block needs writing in the Caddyfile
401, 403, authentication failedDNS_API_TOKEN is wrong, or scoped to the wrong zone
no such host on the challengeThe record is not there yet — propagation is minutes
too many certificates already issuedRate limit. Five duplicate certificates a week; keep the caddy_data volume across restarts
go: module ... not found during the buildThe provider module or one of its dependencies will not compile — see below

The CLI rejects a provider name that is not one of the ninety-six and suggests the closest, because the name is compiled in: an unchecked typo fails several minutes into a Go build, not at start-up.

  $ firetower domain ft.example.com --dns-provider cloudflares
error: no caddy-dns module called cloudflares — did you mean cloudflare?

To get warned before a renewal that has started failing, set ACME_EMAIL in .env, or pass --acme-email you@example.com to firetower install.

This loses work

Building from source means a provider module can be held back by something it depends on rather than by anything in it — caddy-dns/vercel is, today, and Firetower applies the fix for you. If you hit one it does not know about, set DNS_MODULE_REPLACE=old=new@version in .env; it is passed to xcaddy --replace. Delete it once the module's maintainer tags a release.

#Bringing your own certificate

For a corporate CA, a provider with no Caddy module, or a machine that cannot reach a Go module proxy. Answer "I already have a certificate", or install with --domain and no --dns-provider:

sh
firetower install --domain ft.example.com

Put fullchain.pem and privkey.pem in certs/ beside firetower.yml, covering both the name and *.the-name, and uncomment the tls line in the Caddyfile. Renewal is yours again, and firetower doctor warns when there are under three weeks left.

#Next