carlos / carloku Public

🤖 Deploying, channels, domains, config

The first four pages of the customer docs, under "Running your app".

Every command, flag default and behaviour in them is taken from the CLI
reference that shipped on carlosframework.com today (docs/site/cli.md in
the platform repo, verified against Go source) or read out of the
implementation directly — deploy.go for the ship/promote/watch sequence
and its 120s default, ladder.go and pipeline.go for the promotion rules
and the two starter templates, tag.go for what a stable promotion does
and does not cut, domains_cli.go and view.go for the DNS guidance an
attach prints, and appconfig_cli.go for where sealing happens.

Outbound links go to the CLI reference's own anchors rather than
restating flags; each of the ten used was checked against the live page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 126e9207094de50627401813c92ef825e6706608 parent 0940964
4 files changed, +339 −0
  • src/docs/channels.md +101 −0
  • src/docs/config.md +78 −0
  • src/docs/deploying.md +90 −0
  • src/docs/domains.md +70 −0
diff --git a/src/docs/channels.md b/src/docs/channels.md
new file mode 100644
index 0000000..39d535d
--- /dev/null
+++ b/src/docs/channels.md
@@ -0,0 +1,101 @@
+# Channels
+
+A channel is a pointer with a name. It says which shipped version an app
+is serving, and promoting is the act of moving it. Nothing is rebuilt
+between one channel and the next — the bytes that were tested are the
+bytes that go live, which is the whole reason the platform separates
+shipping from promoting.
+
+## You start with one
+
+Every app is created with a single channel called `edge`, and
+`carlos deploy` lands there. That is enough for most apps: you deploy,
+and it is live. To see where things stand:
+
+```sh
+carlos channels -app hello
+```
+
+Nothing is ever promoted *onto* the entry channel. Deploys land there, and
+promotion moves a version from it to whatever comes next — so with one
+channel there is no promotion step at all.
+
+## Adding a stage in front of production
+
+When you want a version to sit somewhere before a wider audience sees it,
+declare a pipeline. The starter template adds a `production` channel
+behind `edge`:
+
+```sh
+carlos pipeline init -app hello -template edge-production
+```
+
+That gives `production` a 24-hour bake window, a passkey requirement on
+promotion, and an approval on any change to those rules — so the
+protection cannot be quietly removed later either. The other template,
+`full-ladder`, sets up `edge → beta → stable` with bakes of 24 and 72
+hours and the passkey on `stable`.
+
+Once a pipeline exists, deploys still land on `edge` and you move a
+version forward by naming it:
+
+```sh
+carlos promote -app hello a1b2c3d production
+```
+
+A channel is entered from the one immediately before it and no other, so
+"it ran on edge for a day first" is a fact about the bytes rather than a
+claim about process. Promoting the same version twice succeeds and
+changes nothing.
+
+The rules a channel can carry — bake windows, passkeys, approval counts —
+are all editable after the fact with
+[carlos pipeline](https://carlosframework.com/docs/cli/#carlos-pipeline),
+and [carlos promote](https://carlosframework.com/docs/cli/#carlos-promote)
+documents the rest of its own flags.
+
+## Putting a version back
+
+Rollback points a channel at whatever it was serving before:
+
+```sh
+carlos rollback -app hello production
+```
+
+The outgoing version goes onto the channel's history as it leaves, so
+running the same command again returns you to where you started — useful
+when the rollback turns out to be the wrong call. A bake window does not
+delay it: a box already has the version it is going back to, so there is
+nothing to hold.
+
+Two things are worth doing before you need this. Give your ships a
+`-label` people can read, because that is what you will be squinting at
+during a rollback. And get familiar with
+[carlos releases](https://carlosframework.com/docs/cli/#carlos-releases)
+while nothing is on fire: it answers "what is there to run", where
+`carlos channels` answers "what is running".
+
+## What reaching stable means
+
+The `full-ladder` shape has a `stable` channel at the top, and a
+promotion onto it can cut a semver tag: one name pointing at one release,
+with the changelog of every iteration that led there compiled into it.
+
+That happens when the version being promoted is an iteration of a target
+rather than a commit sha, so set the target once:
+
+```sh
+carlos version target -app hello 0.5.0
+```
+
+From then on `carlos ship` with no `-version` mints the next iteration
+under it. Promote a sha-named build to stable instead and the promotion
+still lands. There is no tag to cut, and the promotion says so.
+
+Tags are a naming convenience and an audit record, nothing more. No part
+of getting your app running reads one, so an app that never uses them
+loses nothing.
+
+Full flags for the commands on this page:
+[channels](https://carlosframework.com/docs/cli/#carlos-channels) and
+[rollback](https://carlosframework.com/docs/cli/#carlos-rollback).
diff --git a/src/docs/config.md b/src/docs/config.md
new file mode 100644
index 0000000..afe82d8
--- /dev/null
+++ b/src/docs/config.md
@@ -0,0 +1,78 @@
+# Env and secrets
+
+Your app's configuration lives in two stores. Both arrive at your process
+as ordinary environment variables, and from inside the app you cannot
+tell them apart. The difference is what happens to the value on the way
+there, and it is worth getting right the first time.
+
+Plain config goes in `carlos env`:
+
+```sh
+carlos env set -app hello LOG_LEVEL=debug
+```
+
+Credentials go in `carlos secrets`. Same shape:
+
+```sh
+carlos secrets set -app hello STRIPE_KEY=sk_live_example
+```
+
+## Which store a value belongs in
+
+Ask whether you would mind the value appearing in a screenshot. A log
+level, a feature flag, the public URL of a service you call — nobody
+cares, and `carlos env` keeps them readable, which is what you want when
+you are working out why staging behaves oddly.
+
+An API key, a database URL with a password in it, a signing secret,
+anything a third party issued you: `carlos secrets`. If in doubt, the
+sealed store costs you nothing except the ability to read the value back,
+so put it there.
+
+## What sealing actually does
+
+`carlos secrets set` seals the value on your machine, using the
+deployment's public key, before anything leaves your terminal. What gets
+stored is an envelope. The private half of that key lives on the box that
+runs your app and never leaves it, so the console holds a secret it
+cannot itself open.
+
+The consequence is the one thing people trip over: nothing can show you
+the value again. `carlos secrets list` prints key names and stops there.
+Lose the value and you set a new one rather than recovering the old one.
+
+One practical wrinkle. The value is on your command line when you type
+it, so your shell history keeps a copy of it. Clear that out on a machine
+other people use.
+
+## Getting the new value into your app
+
+Changing config converges on its own. The write bumps a marker, every box
+serving the app notices within seconds, rewrites the environment file for
+each of the app's routes, and cycles the process so it comes back holding
+the new value. There is no separate deploy, and no restart for you to
+remember.
+
+Two things follow from the cycling. A config change is a small
+interruption for a running app, so batch related changes into one call —
+`carlos env set` takes several `KEY=value` pairs at once. And a change
+that appears not to have taken effect is more likely to be your app
+reading the variable once at startup than the platform failing to deliver
+it.
+
+To see what is set:
+
+```sh
+carlos env list -app hello
+```
+
+That prints values as well as names, and the sealed store's `list` prints
+names alone. Both families also take `unset` with a key name, and the
+reference pages for
+[carlos env](https://carlosframework.com/docs/cli/#carlos-env) and
+[carlos secrets](https://carlosframework.com/docs/cli/#carlos-secrets)
+have their remaining sub-verbs.
+
+If you take one thing from this page: put the credential in the sealed
+store on the day you first set it. Moving it later means rotating it,
+because by then it has been sitting somewhere readable.
diff --git a/src/docs/deploying.md b/src/docs/deploying.md
new file mode 100644
index 0000000..ab12f05
--- /dev/null
+++ b/src/docs/deploying.md
@@ -0,0 +1,90 @@
+# Deploying
+
+Deploying is getting a build off your machine and onto the URL people
+use, and then knowing — not hoping — that the URL is serving it. One
+command covers all of that.
+
+```sh
+carlos deploy -app hello ./hello
+```
+
+It prints where it is deploying to, then `shipped`, then `promoted`, and
+finally `live` against each hostname. That last line is the one that
+matters. The others say a write succeeded; only `live` says a visitor
+would get the new build.
+
+## What the one command is doing
+
+Three steps, and you can run them separately when you want the gap
+between them.
+
+Shipping hashes your artifact, stores it under its content address, and
+records a manifest that never changes afterwards. Nothing you serve
+changes yet. Promoting points a channel at that version — see
+[Channels](/docs/channels/) for what a channel is and when you would have
+more than one. Then the command watches: it polls the live hostname for
+the `X-Carlos-Version` header until that header names the version you
+shipped, over a 2xx. It gives up after two minutes by default, and
+`-timeout` moves that.
+
+The header is also how you check by hand later, and it is worth
+preferring over anything else you might reach for. A page that looks
+right can be a cached copy of the old build:
+
+```sh
+curl -sI https://hello.q4t.oncarlos.com | grep -i x-carlos-version
+```
+
+## What you ship
+
+The version defaults to `git rev-parse --short HEAD`, so a deploy from a
+clean checkout is already labelled with the commit it came from.
+`-version` overrides it.
+
+Binaries are the common case, and `-target` says what you built for. It
+defaults to `linux-arm64`, which is what Carloku runs, so a Go build for
+this platform is:
+
+```sh
+GOOS=linux GOARCH=arm64 go build -o hello .
+```
+
+A directory of files is a site rather than a binary, and takes
+`-kind static`:
+
+```sh
+carlos deploy -app hello-site -kind static ./public
+```
+
+Run any of these once from your project and the CLI offers to remember
+the app and the artifact in `.carlos/config`. Say yes and the whole
+release becomes `carlos deploy` with no arguments, which is the form to
+put in a Makefile or a git hook.
+
+## When it doesn't go live
+
+A deploy that stops short usually stops for a reason it will tell you.
+
+If the channel you are promoting into needs approvals, the ship still
+lands and the promote becomes a proposal: the CLI says how many
+confirmations it needs and where to confirm them. If the channel has a
+bake window, the promote lands and the fleet adopts when the window
+expires — the command exits successfully, because the hold is policy
+rather than a failure. And if no instance follows the channel yet, it
+says so instead of watching a URL that nobody serves, and names
+`carlos instances create` as the step that gives the version somewhere to
+run.
+
+A watch that times out is a different situation. The version is published
+and the pointer has moved, so your app is either failing to start or
+failing its first request. Go to the logs:
+
+```sh
+carlos logs -app hello -since 10m
+```
+
+Full flag lists for
+[deploy](https://carlosframework.com/docs/cli/#carlos-deploy),
+[ship](https://carlosframework.com/docs/cli/#carlos-ship) and
+[promote](https://carlosframework.com/docs/cli/#carlos-promote) are in
+the CLI reference, and `carlos deploy -h` prints them at your terminal.
diff --git a/src/docs/domains.md b/src/docs/domains.md
new file mode 100644
index 0000000..f2775fc
--- /dev/null
+++ b/src/docs/domains.md
@@ -0,0 +1,70 @@
+# Custom domains
+
+Your app already answers on a hostname the platform gave it, something
+like `hello.q4t.oncarlos.com`. Attaching your own domain is two moves:
+tell Carloku the hostname is yours, then point a DNS record at it. The
+platform hostname keeps working afterwards, so you can do all of this
+before you switch any traffic over.
+
+```sh
+carlos domains attach -app hello www.example.com
+```
+
+Leave `-route` off when the app has one instance — the command works out
+what to point the hostname at. With several, name the one you mean.
+
+## The DNS record
+
+Attach finishes by printing the record to create and the hostname's
+current DNS state, so you need neither a second command nor a trip to the
+console to know what is left. Usually that is an A record at this
+deployment's edge addresses, named for you rather than left to look up.
+Where the deployment offers a steering hostname, a subdomain is offered a
+CNAME at that instead: visitors then reach the nearest healthy edge, and
+a failed edge drops out of the answer on its own. An apex domain —
+`example.com` with nothing in front of it — always takes the A record,
+because a CNAME cannot legally sit at the apex.
+
+Create the record with whoever runs your DNS. Checks run every few
+minutes, so there is nothing to trigger.
+
+## Watching it come live
+
+```sh
+carlos domains list -app hello
+```
+
+Each row is one claim joined to what the fleet actually observes: which
+route it points at, whether the hostname resolves to the edge, when its
+certificate expires, and — while it isn't pointing yet — the same
+instruction attach printed. Certificates are minted and renewed for you;
+there is no step here you own.
+
+The claim itself reaches the boxes within seconds of the attach, so a row
+that still reads `pending` a minute later is unusual. The DNS column is
+the slow half. It stays unhelpful until your record has propagated, and
+nothing on this side can hurry that along.
+
+## Taking one off
+
+```sh
+carlos domains detach -app hello www.example.com
+```
+
+The boxes serving the app notice within seconds and stop answering for
+the hostname, the same way they picked it up when you attached it. Your
+DNS record is yours to clean up afterwards.
+
+## Wildcards
+
+Two flags on
+[attach](https://carlosframework.com/docs/cli/#carlos-domains) cover the
+give-every-customer-a-subdomain case. `-wildcard` enables a delegated
+wildcard certificate for the hostname; `-catchall` derives a `*.<host>`
+alias onto the same route. Both need DNS delegation in place, and attach
+refuses with the reason when it isn't. Attaching one marketing domain
+needs neither.
+
+Neither flag has a default position either. Naming one changes it and
+leaving it off leaves it alone, so you can turn a wildcard on months
+later without restating anything else about the claim.