projects / archon
Archon
A Go terminal client and a small node agent on each server for hosting Docker sites across several machines, with the reverse proxy, SSL certificates and DNS records set up for you.
....................
=dOdddddddddddddddddddd;
:::::::::::::::, *bKKbkkkkkkkkkkkkkkkkkkkk-
=Okkkkkkddddddkdl` ;0XbOdxkkkkkkkkkkkkkkkkkkk;
*dcoooooooooooox*- -KOOXdkkkkkkkkkkkkkkkkkkkk-
+doooooooooooooxl!-------;-======~~~~~+**+=======+!******++=iXOOXdxkkkkkkkkkkkkkkkkkkk;
!boxxxxxxxxxxxxx!=``````,''''''''''''''''......... .. -KOOXbkkkxkkkkkkkkkkkkkkkk-
.'`:;--:!xllllllllcllllc+~ -KXObkockbooooooooooooooxx;
.'`:;-;;:,`'. ,=======~kd-===+- ;Ol!!=-ibi=+++==++++++==-:.
'-----------------,. '`,;;;;;:`'. ........`M* '.. :cb*~Oc;-~~~~=+~~~~~-,'
oObbbbbbOOOOOOOOOb*~=~, '`,;---;:,'. =@' .+kl`~k~ ;
xxooooooxxxxxxxxxo-;~~=-;:,'. dO *Xdkl' co' :,
xkxxxxxxxxxxxxxxkx--~~- .@* ;Xdo~ -d+ .;
oxxxxxxxxxxxxxxxxx----- =@` -ok;`.'ox' :`
xkxkkkkkkkkkkkkkkx--l*~ dX =xo~ ~k* ';
!iiillllllllllllli-~lxkc*-' .@i .*kc+llicbklilllllc~ ;`
''`,`,,,,,,,,,,`,-;,':+cxoi=, ~@, '!kl, ~bokbc!lllllllc= ';
.`` `~ioxc*:. kK ,ld!' -dOxillllliilll~ ;'
.`,' ';!ckocbi. @l ;ok+. lKollllccccolic= `:
`,' ;bc+xc+: =@: ~xx~ `ok~~--~~~~~~+cl~` ;.
'``. ..`-ioxo!-' d0 .+ko; `;~xK* -*; ,:
,, ... `;*oxxi~, Wc,=**=-` `!dl` ':=!=+bk, -!~. ;.
;ox*=loxxxxxxki+; ,=lxkc*:. .*K@@@@@@MKk*' '!o+ cd+=-, -x= ;!=' ,,
!xlcl*lccccclc~;~ `~ixkxi~` *W@@@@M@@MMWWM0cxWXc!iOWXdl!!x@k!ii+;` :!+` .;
*ollcccllccxkkli!:`'. ,=ckxc+: k@@@@@X@@MM@@@M@@@@@@@@@M@@@@@@M@@@@@@WO- ,+=' ``
*xccccccoccccoiic!lccccli!*=-,. .,-!cK@@@@@@0Kbdddddddddddddddddddddddddddddbk* :iOo===+;~======-:`.
=l!iii!cc!iiii~-: .`,;~+!lcoxxocic@@MMMMWKOkxkkkkkkkkkkkkkkkkkkkkkkkkkkxxkc~ .bOXW0KKKxXKKKKKKXObx`
..... i: ..... .',:cM0000KKKOkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkc=:::,```````'''''.`xkkdxcccxcccccccccoo,
'l -0OXOOObKOkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkc=====~=+++++===+**+llldkooooooooooooooo,
+= 'bdkkkkkXOkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkc= .xxxdxooooooooooooooc,
c' ;xcccclOOkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkc= .okxbdxxxxxxxxxxxxxxx,
:l =di!**+OXkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkc= :ci+===============.
i~ +do:`~=~-bXkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkc= '=*;
.x. .!dc, `::bXkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkko= `+!;
~i 'idi` '+OOxxxxxxxxooooxxxxxxxxxxxxxxxxxxxl~ ,**:
o; ,cd!' '=co*:---------==*=-------------------:` :!+`
`x :od*. .~lo!: lOo~ ;!+'
!! -xk+. .~lo!; ,:;. ~!=.
k, ~kk~ .-ioi;. ,;' .=i~
;x =dx- ;!oi;. ,-' '+!-
c= .*do: ;!oi-. `-' `*!:
.d' 'ibc` :!ci~. ,-` :**,
=c `lb!. `*cl~' .:` ;!+`
x- ,cd+''=cl~' =kxxx:=kxxxxxxxxo+.
`k :cx+,~ll=` O@@@@O=K@@@@@@@@@k.
+dbdddddbdK0ddddddddkl!l=` kWOOOKXOOOOOOOOOO0!
.+b00WWMMMM@@@@@@@@@@@boc; kXoooooxooooooooox~
.-loxxkkddbbOOOXXXKKKbxl=-+' ..''`,`:,:;;-;-----~;cxooooooooooooooco~
`-;;-~~~==+++***!!!!!**;:--' ...''``,,,,:::;:;;:;:;:::,:,,```''.. *c****************;
....''''`````,,,,,::::''`````````'''''''.... `=;;;;;;;;;;;;;;;;`
..... . .................
Overview
Archon hosts Docker-based websites across several servers from one place. Each server runs a small Go service, which I call the node. It pulls images, starts containers, writes the Nginx, Apache or Traefik config and gets Let's Encrypt certificates through certbot. A client on my laptop keeps the list of sites, domains and nodes, and sets up DNS in Cloudflare. It refuses to deploy until those records point at the right server.
The first client was a Go terminal UI in Bubble Tea, and in March 2026 I added a Rust desktop client in Floem. I worked on it from December 2025 to March 2026 and tagged three releases, then I paused it. Single-container sites work, and the screenshot below is from my own setup. Some parts are marked done in my planning docs but don't work end to end, and I go through those further down.

The problem
Putting one container on the web by hand takes a fixed list of steps. You docker run it on a free port, add an A record, wait for it to resolve, write a port-80 vhost so certbot can answer the HTTP-01 challenge, run certbot, rewrite the vhost for HTTPS, test the config and reload. The order matters. certbot fails if DNS hasn't propagated yet. Nginx won't reload while a vhost points at a certificate that doesn't exist. And one bad vhost takes down every site on the box.
I wanted those steps run in the right order on any of my servers, undone if one failed, with one list of what runs where.
How it works
The client holds the state
The client stores everything as TOML files under ~/.config/archon/, one per site, domain and node. Nodes have no database. They find their containers by Docker labels (archon.site.id and friends), and every deploy request carries the whole site definition. So a node can be wiped and reinstalled with nothing to migrate. The cost is that the list of what runs where only exists on my laptop, and two clients can't safely share a node. That's the one thing still open.
Deploys as a staged pipeline
At first the node did a deploy as one long handler. When I added Docker Compose support in January 2026, I added a phase part way through to rebuild it as a pipeline "for cleaner extensibility". Each step is a Stage with Execute and Rollback, run in a fixed order: validation, port check, SSL setup, deployment, proxy config.
func (p *Pipeline) Execute(ctx context.Context, state *DeploymentState) (err error) { var executed []Stage // Defer rollback in case of error (including panic) defer func() { if err != nil { p.rollback(ctx, state, executed) } }() for _, stage := range p.stages { // ... state.CurrentStage = stage.Name() state.EmitProgress(stage.Name(), "started", fmt.Sprintf("Starting %s", stage.Name())) if err = stage.Execute(ctx, state); err != nil { state.Error = err state.EmitProgress(stage.Name(), "failed", fmt.Sprintf("Failed: %v", err)) err = fmt.Errorf("stage %s failed: %w", stage.Name(), err) return } executed = append(executed, stage) state.CompletedStages = append(state.CompletedStages, stage.Name()) state.EmitProgress(stage.Name(), "completed", fmt.Sprintf("Completed %s", stage.Name())) } return nil}// rollback calls Rollback on executed stages in reverse order.// Logs errors but continues rollback for remaining stages.func (p *Pipeline) rollback(ctx context.Context, state *DeploymentState, executed []Stage) { for i := len(executed) - 1; i >= 0; i-- { stage := executed[i] state.EmitProgress(stage.Name(), "rollback", fmt.Sprintf("Rolling back %s", stage.Name())) if rollbackErr := stage.Rollback(ctx, state); rollbackErr != nil { // Log but continue - don't let rollback errors stop other rollbacks log.Printf("[ROLLBACK ERROR] Stage %s: %v", stage.Name(), rollbackErr) } }}
The named return value makes it work. Every failure path sets err, and the deferred functionL5-9 checks it on the way out. A stage only joins the executed listL23 once it succeeds, and rollback walks that list backwardsL34. A failed rollback is logged and skippedL38-41, so one stuck cleanup doesn't block the rest. Stages pass results (certificate paths, container ID) through a shared DeploymentState. The handler turns the failing stage into a status code: 400 for validation, 409 for the port check, 500 otherwise.
Reading it again now, the rollback is weaker than it looks:
- The failing stage itself is never rolled back. Proxy config is last, so its
Rollbackcan never run, and a vhost that failsnginx -tstays on disk. - The commentL4 says "including panic", but a panic doesn't set
err, so nothing rolls back. chi'sRecoverercatches it instead. - The Docker step removes the old
archon-<name>container before creating the new one. Rolling back a failed redeploy deletes the site instead of restoring it.
Certificates before containers
I got round the ordering problem by putting SSL before the container and the final vhost:
func (s *SSLStage) Execute(ctx context.Context, state *pipeline.DeploymentState) error { req := state.Request // ... // Configure proxy for Let's Encrypt validation if err := s.proxyManager.ConfigureForValidation(ctx, req); err != nil { return fmt.Errorf("failed to configure proxy for validation: %w", err) } // Reload proxy if err := s.proxyManager.Reload(ctx); err != nil { return fmt.Errorf("failed to reload proxy for validation: %w", err) } // Wait for DNS propagation for _, domain := range domains { if err := s.waitForDNS(domain, 60*time.Second); err != nil { return fmt.Errorf("DNS propagation timeout for %s: %w", domain, err) } } // Request certificate certPath, keyPath, err := s.sslManager.EnsureCertificateMulti(ctx, req.ID, domains, req.SSLCert, req.SSLKey, req.SSLEmail) if err != nil { return fmt.Errorf("failed to obtain SSL certificate: %w", err) } // Store paths in state for proxy stage state.CertPath = certPath state.KeyPath = keyPath // ...}
FirstL5-7 it writes a temporary port-80 vhost that serves /.well-known/acme-challenge/, and removes any old HTTPS vhost for the domain so Nginx can reload. ThenL15-19 it polls DNS every two seconds, for up to a minute per domain. Only thenL22 does it run certbot, with every domain as a -d flag, so one SAN certificate covers the site. Before that, the SSL manager parses any certificate already in /etc/letsencrypt/live/ and reuses it if its SANs cover every domain.
The proxy stage renders the real vhost from a Go text/template. It redirects HTTP to HTTPS, allows only TLS 1.2 and 1.3, sends HSTS, and proxies to 127.0.0.1:<host port> with WebSocket upgrade headers. The node runs nginx -t before every reload, so a bad vhost can't take down the other sites. Apache uses certbot's --apache plugin. Traefik is meant to work through container labels, but the TUI's label generator is still a TODO that returns an empty map.
DNS stays on my laptop
The node never touches DNS. Pressing r on a site creates the missing A records in Cloudflare (TTL 300, tagged with the site name) and fixes any that point at the wrong IP. Before every deploy the client lists the zone and stops unless every domain has an A record matching the node's IP. The point is that the Cloudflare token never leaves my machine, so a compromised node can't touch my DNS. Route53 shows up in the README and the forms, but its provider just returns "not yet implemented", so only Cloudflare and manual DNS actually work.
Two deploy paths, and the gap between them
The Go TUI doesn't use the pipeline. Before the pipeline existed, I moved TUI deploys onto a WebSocket, because a deploy that pulls an image, waits for DNS and runs certbot easily outlasts the node's 15-second HTTP write timeout. That handler is still the older hand-written sequence:
// Validate request if req.Name == "" || len(req.DomainMappings) == 0 || req.Docker.Image == "" { sendError(conn, "Missing required fields") return } // ... // Deploy container sendProgress(conn, "Deploying Docker container: "+req.Docker.Image, "docker") deployResp, err := h.dockerClient.DeploySite(ctx, &req, h.dataDir)
A compose site has no image, so this checkL2-5 rejects it. Even with an image, the deployL9 goes straight to the single-container client with no rollback. My planning docs mark the compose milestone done, after unit tests and a manual checklist. In practice, a compose site created in the TUI fails with "Missing required fields". The node streams a progress message for every step, but the TUI passes nil as the callback ("just use WebSocket for timeout prevention"). The REST path is the other way round. It has the pipeline, compose and rollback, plus an OnProgress hook that nothing sets. The Rust GUI deploys over REST, so it's the first client where compose can work. The fix is to run the pipeline inside the WebSocket handler and wire OnProgress to sendProgress.
Credential encryption and health checks
Private images need registry credentials in the request. When I planned it, I wrote that I didn't mind the API being plain HTTP, but wanted a KDF on the sensitive values. Client and node share the same file:
func DeriveKey(apiKey string, salt []byte) ([]byte, error) { if len(salt) < SaltSize { return nil, fmt.Errorf("salt must be at least %d bytes", SaltSize) } return pbkdf2.Key([]byte(apiKey), salt, PBKDF2Iterations, KeySize, sha256.New), nil}// ...func Encrypt(plaintext, apiKey string) (string, error) { // Generate random salt salt := make([]byte, SaltSize) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("failed to generate salt: %w", err) } // Derive key from API key and salt key, err := DeriveKey(apiKey, salt) // ... // Generate random nonce nonce := make([]byte, NonceSize) if _, err := rand.Read(nonce); err != nil { return "", fmt.Errorf("failed to generate nonce: %w", err) } // Encrypt the plaintext ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil) // Combine salt + nonce + ciphertext result := make([]byte, 0, SaltSize+NonceSize+len(ciphertext)) result = append(result, salt...) result = append(result, nonce...) result = append(result, ciphertext...) return base64.StdEncoding.EncodeToString(result), nil}
PBKDF2-SHA256L5 with 100,000 iterations turns the node's API key into an AES-256 key. Every call uses a fresh saltL9-13 and nonceL18-22, packed in front of the ciphertextL28-31, so the message carries everything but the key. GCM's tag makes a tampered value fail to decrypt, not turn into a wrong password. The node decrypts, then logs the request with the credentials replaced by [REDACTED]. The Rust GUI has a byte-compatible port.
Health checks run on a Bubble Tea tea.Tick, every five minutes by default. Each tick sends one command per node to /health and one per site to its status endpoint, skipping sites that are mid-deploy. A node is online if Docker and the proxy both respond, degraded if one does, and offline if neither does.

Security considerations
I study cyber security, so I'll be blunt. As it stands, I wouldn't put a node on the open internet without a firewall in front of it. What's done:
- Every route that changes anything needs the Bearer key. Keys are 24 bytes from
crypto/rand. - Registry credentials are encrypted and redacted in logs. Manual SSL private keys are written
0600. - certbot,
nginx -tanddocker composeget argument lists, not shell strings built from user input. - Generated vhosts allow only TLS 1.2 and 1.3 and send HSTS. Nothing deploys until DNS points at the right node.
- The README says to firewall port 8080 to your own IP, or keep nodes on a VPN.
What isn't, worst first:
- No TLS on the node API. The API key crosses the network in plain text, and it's also the KDF input. Anyone sniffing gets the key and everything it encrypts. The encryption keeps secrets out of logs, but it isn't transport security.
- Quick configure uploads the API key to dpaste.org. It posts the node's whole config,
api_keyincluded, to a public paste site for an hour. I had planned to delete it after five minutes or once the node had fetched it, but that was never built.configure --from-fileover SSH already exists and should be the default. - One key means root. The node runs as root with the Docker socket, and a request can bind-mount any host path. There are no scopes, no rotation and no rate limiting.
- Weak key handling. The key is checked with
!=instead ofsubtle.ConstantTimeCompare. Ifcrypto/randfails, the generator falls back to the current time. - No input validation. Domain names go straight into Nginx templates and file paths, and config file names aren't checked for
../. - Smaller leaks. Container ports bind to
0.0.0.0, so the proxy can be bypassed. Client secret files are0644./healthshows the Docker version without auth. The WebSocket accepts any origin.
History
- 14 to 19 December 2025: the TUI and node from nothing in six days. Docker and Nginx came first, then Cloudflare records, the WebSocket deploy, DNS setup, Apache and SAN certificates. Tagged
V1.0.0. - 17 January 2026: host-port mapping and the whole Docker Compose milestone in one day. Five of its eleven phases were added part way through, which were the pipeline rewrite, a fix for two changes that clashed, and three rounds of TUI form bugs. I released it as v1.0.0 and v1.1.0 that evening.
- 5 February 2026: one big update added credential encryption, health checks, quick configure, archive-on-delete and test mocks. What I'd planned as "site rollback" became the archive. Restore exists and is tested, but no screen calls it yet.
- 2 to 3 March 2026: volume mounts, then a Rust desktop client in Floem, about 8,000 lines in two days. I tagged it v2.1.0 "GUI upgrade", and that was the last work I did on it.
The TUI kept its state in one model and a 3,800-line handlers.go, with forms indexed by number (FormFields[3]). Every new field risked breaking the others, and the inserted phases show it. The Rust client splits models, config, API, DNS, crypto and UI into modules, with a reactive signal per form field.
I picked Rust mostly because it was what I'd been working in most recently and it was fresh in my mind, so I wanted to try it here. It didn't live up to everything I'd hoped. Rust could certainly have done what I wanted, but I didn't have the time to sink into it, or to keep upgrading everything just to make small updates. The Rust client still lacks DNS record creation, automatic health checks and quick configure.
Outcome
Archon is paused, not finished. For a single-container site it does what I built it for. One key sets up DNS, and one more gets the container running behind Nginx with a valid certificate. The pipeline is the part I'm happiest with, because a new step is one type with Execute and Rollback.
What I learned:
- Done in the docs isn't tested end to end. Compose passed unit tests and a checklist, but the only client that could create compose sites used a path that rejects them. One test through the real TUI path would have caught it.
- Two code paths for one job drift apart. I built the pipeline next to the old handler instead of replacing it, and compose, rollback and progress each landed on only one side.
- A key used for a KDF is only as safe as the channel it travels on. TLS on the API should have come before the credential encryption.
- Keep the repo tidy from day one. A 12 MB debug binary is still sitting in the repo, and the README advertises Route53 and links a LICENSE file that doesn't exist.
If I pick it up again, I'd run the pipeline behind the WebSocket and stream its progress, add TLS to the node (or make WireGuard the documented default), replace the dpaste flow with SSH or a one-time token, validate names and domains, and decide how several clients can share nodes. That means either the nodes keep a site index or the clients share one. Related: Bosun's deploy manifest can generate a Caddyfile for the same job, and Gantry's optional Go-driven UI uses the same Bubble Tea style.