The workbench is two things in one repository.
- A portable development environment for Phoenix. You create a project and run it on
localhostwith nothing installed on the host but Docker: no Elixir, no Erlang, no Node, no database server. The project carries its own Docker files, so a teammate who clones it gets the same environment you have. - A knowledge base of the Elixir ecosystem, kept as cartridges. A cartridge is one configuration or implementation (static analysis, health probes, clustering, a database admin) packaged together with the papers that explain it: what it installs, the need it answers and why it is designed that way, with sources. Writing a cartridge makes you learn the theory behind it. After that it goes into any future project as one commit, and its papers and the files it wrote stay there to read when you need to remember how it works.
It has three parts, and this README presents them in order, so each section only uses what an earlier one explained:
wb.sh, a shell script that creates projects and runs every mix and git step inside a container.- The cartridges (
igniter/), a package of Igniter installers that patch a project through its AST. - The console (
console/), a Phoenix LiveView application that drives both from the browser.
Four uses, each with the place in this document that shows it.
- Learning and teaching Elixir and Phoenix. Every cartridge explains itself: the need it answers, what it installs and why, with sources, and then the code it wrote in your project, as a diff. The same shelf works at three sizes. For one person it is a record of what you learned, kept in a form that runs. For a team it is onboarding: a new developer sees how the project was born, what each package is for and which cartridge brought it. For a class it is the material. See The papers, the Manual and Files screens of The Cartridges tab, and the Birth paper of Project.
- Reusing what you worked out. A setup you got right once becomes a cartridge, and goes into the next project as a commit instead of being copied from the last one. It can be a library with its configuration, a service in the compose, or a whole architecture as a collection of cartridges. A design system would be the same thing, though no cartridge ships one today. Unlike a starter template, a cartridge goes into a project that already exists, and comes out again. See One commit in, one revert out and Base cartridges, collections, archived and pending boxes.
- Evaluating before adopting. Insert a cartridge, read its diff, run it, and eject it with one revert if it is not what you wanted. Some boxes put the alternatives side by side as options (four database admins, two test-double libraries, four databases). The three deployments show the result in development, as a release and as a cluster, and
k6andmonitoringmeasure it. See the Installation screen of The Cartridges tab, Deployments and The shelf. - Understanding what you run, whoever wrote it. Code is cheap to generate now, and understanding it is not. A change an assistant produces is different every time and arrives without its reasons. A cartridge is the other kind of object: the same installer makes the same change, it has a version, and its reasoning is written beside it with the sources. A person can insert it or an agent can (
./wb.sh catalog --json --briefandstatus --json --briefexist so an agent can read the workbench), and either way the result is a commit you can read, explain and revert. Writing a cartridge is where the learning is forced: itsDESIGN.mdcannot be written without knowing why. Using one forces nothing, but it leaves the explanation next to the code for the day you need it. See Writing a cartridge and Why it is shaped like this.
One limit to know from the start: your own cartridges live in the workbench's repository, so a personal shelf or a team's is a fork of it.
You need Docker running and a bash shell to start the script from. Nothing else. The workbench has been tested on Linux; trying it on macOS and Windows is still pending.
On Linux, use the native Docker Engine. Docker Desktop works too, but it builds images much more slowly: measured once on the same machine, the workbench image took about two and a half minutes on the Engine and fourteen on Desktop, while compiling and running tests took the same on both. ./wb.sh engine native selects it.
git clone https://github.com/JosePamplona/Dockerized-Elixir-Workbench.git
cd Dockerized-Elixir-Workbench
./wb.sh console # builds the workbench image (about four minutes, once), then http://localhost:4100In the console, press Create project and then Up on the dev row. The rail on the left shows the address the application answers on, the first free port from 4000.
The same from a shell, without the console:
./wb.sh new --name "My App" # a stock Phoenix project, into the workspace config.conf names
./wb.sh up # dev, detached; ./wb.sh logs follows itThat is a stock Phoenix application, running. What goes into it next is the subject of Cartridges.
Two words are used through the rest of this document.
- The workbench is this repository. It stays in its directory and never changes shape, whatever you build with it.
- A workspace is the directory a project is generated into. It holds the project's source, its git repository and its own Docker files, with the project's name and host ports written into them. Because each workspace owns its orchestration, several projects run side by side.
Workspaces go under _workspaces/ by default, but nothing requires it. WORKSPACE_PATH takes any path, relative to the workbench or absolute, so a project can live next to your other repositories.
📁 Dockerized-Elixir-Workbench/ # the workbench
├── 📄 wb.sh # the CLI
├── 📄 config.conf # what the next project gets: workspace, name, stack versions
├── 📁 igniter/ # the cartridges
├── 📁 console/ # the console
└── 📁 _workspaces/
├── 📁 lorem_ipsum/ # a workspace: one Phoenix project, its compose files, its git history
└── 📁 dolor_sit_amet/ # another one, running beside it on its own ports
Every command runs in a container on the workbench image, which carries the Elixir toolchain, the Phoenix installer and the Docker CLI. That is why the host needs nothing but Docker: mix phx.new, mix deps.get, the cartridges' installers and even git commit happen inside it.
A project does not depend on the workbench afterwards. The workspace is an ordinary Phoenix repository with an ordinary docker-compose.yml, so docker compose up works in it with or without the workbench. The only trace is one conditional line in mix.exs, which makes the cartridges' Mix tasks available while the workbench is mounted and adds nothing when it is not.
config.conf is the one configuration file. Each setting is explained above its line in the file itself. It decides what the next project gets. An existing project is described by its own files, never by this one.
| Setting | What it decides |
|---|---|
WORKSPACE_PATH |
The directory the project is generated into, and the workspace every command acts on. Any path, relative to the workbench or absolute. |
PROJECT_NAME |
The name of the next project. The OTP app, the module, the images and the compose project derive from it. |
ELIXIR_VERSION, ERLANG_VERSION, DEBIAN_VERSION |
The stack: the three parts of one hexpm/elixir image tag. ./wb.sh stacks lists the usable ones. |
PHX_NEW_VERSION, NODE_VERSION |
The Phoenix installer to generate with (empty takes the newest that runs on the stack) and the Node major. |
GIT_IDENTITY |
Who signs the commits the workbench makes in the workspace: your host identity or the workbench's own. |
JOB_NICENESS |
The CPU priority of what compiles, so the machine stays usable meanwhile. |
*_IMAGE_VERSION |
The image tags of the services cartridges bring: Postgres, MySQL, pgAdmin, nginx, Grafana and the rest. |
There is no port setting. Each workspace takes the first free host ports when it is created and writes them into its own compose file, which is where you change them. A port counts as taken when something is listening on it, or when another workspace under _workspaces/ has it in a compose file, running or not. A workspace kept elsewhere gets only the first of those two checks.
A project is born with three ways to run, each one a compose file in the workspace. A deployment is one of them, brought up.
| Deployment | Compose file | What it runs |
|---|---|---|
| dev | docker-compose.yml |
The dev toolchain image with the source mounted. It recompiles on change, and creates, migrates and seeds the database on boot (mix setup). |
| prod | docker-compose.prod.yml |
The release image, built from the project's Dockerfile (Phoenix's own, from mix phx.gen.release --docker). A one-shot migrate service runs first and the app waits for it. |
| scaled | docker-compose.scaled.yml |
Several release replicas behind an nginx balancer, four by default. With the clustering cartridge inserted they form a real BEAM cluster. |
In dev and prod the services follow the pod pattern: one pod container owns the network namespace and the published ports, and every other service joins it. They all reach each other on localhost, so the project keeps Phoenix's default configuration untouched (the database is at localhost:5432, as phx.new wrote it). The database port is never published to the host.
scaled drops the pod so every replica gets its own address. The replicas share one DNS name, which is what DNSCluster queries to find its peers.
Writing a compose file from the project as it is now is called baking. The workbench bakes on its own whenever a cartridge adds or removes a service. Hand edits meant to survive the next bake go in docker-compose.override.yml, which Compose reads by itself and the workbench never touches.
./wb.sh help documents every command and option. This is the map:
| Group | Commands | What they do |
|---|---|---|
| Create | new, adopt, delete |
Generate a project, take in a Phoenix project made elsewhere, delete the workspace's project. |
| Run | up, stop, down, restart, ps, logs, build, bake |
Bring a deployment up or down (--deploy dev|prod|scaled), follow it, rebuild its image, write its compose file again. |
| Work inside | mix, iex, bash, k6 |
Run a mix task, open IEx on the running node, open a shell, run a load test. |
| Cartridges | catalog, status, add, eject, expand |
List the cartridges, say what the project carries, insert one, remove one, preview an insert. |
| Git | commit, discard |
Commit or throw away the pending changes of the workspace. |
| Workbench | console, config set, stacks, engine, prune, login |
Run the console, edit config.conf, pick a stack, switch Docker context, clean up, log in to a private registry. |
./wb.sh --yes COMMAND answers every confirmation, for scripts. status --json and catalog --json are the machine-readable forms, the ones the console reads.
A cartridge is one feature a project can take: a library with its configuration, a piece of code, a service in the compose file, or all three. Technically it is an Igniter installer, a Mix task that edits the project through its AST instead of with text substitution, so it finds the right place in your mix.exs, endpoint or router even after you changed them.
The name comes from how it is used: you take a box off a shelf, read what it says, insert it, and take it out again if it was not what you wanted.
./wb.sh add health_probe # one commit: "Insert health_probe"
./wb.sh eject health_probe # reverts that commitEvery insert is exactly one commit in the workspace's git history. That gives three things for free:
- You can read what it did. The commit's diff is the complete list of files the cartridge wrote or changed.
- You can undo it.
ejectreverts the commit, and refuses if you changed those files since, or if another cartridge builds on it. What the cartridge had told git to ignore (a generated report, a file with this machine's address) is removed with it, andejectnames each file it removes. - It can be asked whether it is there. Each cartridge answers
statusby looking at the same mark its installer checks before touching anything, so the two never disagree.
A cartridge that brings a container (a database admin, Prometheus) also declares its compose services, and they are baked into the three compose files in that same commit.
This is the knowledge-base half. Each cartridge is a directory under igniter/lib/workbench_igniter/features/, and beside its code it keeps four papers:
| Paper | What it answers |
|---|---|
NEED.md |
The developer's situation in one sentence, then Before, After and Not for. It is what the box says on its front. |
README.md |
What it installs, its options and how it is wired, file by file. |
DESIGN.md |
Why it is built this way: the problem, the background with primary sources quoted, each decision with the alternatives it beat, what was verified and what was not. |
CHANGELOG.md |
The cartridge's own versions, with semver applied to what it installs. |
The DESIGN.md is the one that takes work, and it is the point. To write that health_probe answers liveness and readiness on two routes from the first plug of the endpoint, you have to read what Kubernetes, Fly.io and AWS ECS actually do with each answer. Months later the cartridge inserts in seconds, and the paper is still there for whoever wants to know why. Three good ones to start with: health_probe, clustering and ecto.
The cartridges on offer today, one to a row. The cover links to the cartridge's directory, and the last column to its papers.
On the shelf — 17 cartridges, inserted when the project asks for them:
| Box cover | Cartridge | What it installs | Papers |
|---|---|---|---|
![]() |
version_managerv0.1.0 |
Pins the Erlang and Elixir the project runs on, for the host's version manager | README NEED DESIGN CHANGELOG |
![]() |
changelogv0.5.2 |
Starts versioning in the project: a changelog opened at the version it is on; the mix version task and the README badge on request | README NEED DESIGN CHANGELOG |
![]() |
dashboard_extrasv0.1.0 |
Switches on LiveDashboard's OS Data and Ecto Stats pages | README NEED DESIGN CHANGELOG |
![]() |
credov0.2.0 |
Adds Credo static code analysis to the project | README NEED DESIGN CHANGELOG |
![]() |
test_doublesv0.1.1 |
Installs the test double libraries: Mimic, Mox, or both | README NEED DESIGN CHANGELOG |
![]() |
exdebugv0.1.0 |
Installs ExDebug: a framed look at what passes through a pipeline, printed in :dev and :test only | README NEED DESIGN CHANGELOG |
![]() |
coveragev0.12.0 |
Adds test coverage reports to the project, measured by ExCoveralls | README NEED DESIGN CHANGELOG |
![]() |
exdocv0.9.1 |
Adds the ExDoc documentation site to the project | README NEED DESIGN CHANGELOG |
![]() |
precommitv0.1.3 |
Runs the project's checks before the commit exists | README NEED DESIGN CHANGELOG |
![]() |
test_datav0.1.1 |
Adds test factories and Faker, shaped by the project's line | README NEED DESIGN CHANGELOG |
![]() |
clusteringv0.3.1 |
Boots the production release as a distributed node for DNSCluster | README NEED DESIGN CHANGELOG |
![]() |
health_probev0.3.0 |
Adds liveness and readiness probes as the first plug of the endpoint | README NEED DESIGN CHANGELOG |
![]() |
ashv0.9.0 |
Installs the Ash framework, configured like ash-hq.org's installer for an existing app | README NEED DESIGN CHANGELOG |
![]() |
mishka_chelekomv0.1.0 |
Generates Mishka Chelekom's components into the project, all of them or the ones chosen | README NEED DESIGN CHANGELOG |
![]() |
db_adminv0.2.1 |
Adds a database admin to the workspace, open on the project's database | README NEED DESIGN CHANGELOG |
![]() |
k6v0.1.0 |
Adds k6 load testing to the workspace, with a smoke test to start from | README NEED CHANGELOG |
![]() |
monitoringv0.1.0 |
Adds PromEx to the app, and Prometheus with Grafana to the workspace | README NEED DESIGN CHANGELOG |
Base cartridges — the 7 capabilities phx.new decides at birth, each one addable afterwards:
| Box cover | Cartridge | What it installs | Papers |
|---|---|---|---|
![]() |
mailerv0.3.0 |
Adds Phoenix's Swoosh mailer, as phx.new would have generated it | README NEED DESIGN CHANGELOG |
![]() |
gettextv0.2.0 |
Adds Phoenix's gettext, as phx.new would have generated it | README NEED DESIGN CHANGELOG |
![]() |
ectov0.3.2 |
Adds Phoenix's Ecto with a database adapter, as phx.new would have generated it | README NEED DESIGN CHANGELOG |
![]() |
esbuildv0.2.0 |
Adds Phoenix's esbuild JavaScript bundling, as phx.new would have generated it | README NEED DESIGN CHANGELOG |
![]() |
tailwindv0.2.0 |
Adds Phoenix's Tailwind CSS pipeline, as phx.new would have generated it | README NEED DESIGN CHANGELOG |
![]() |
htmlv0.3.0 |
Adds Phoenix's HTML views, as phx.new would have generated them | README NEED DESIGN CHANGELOG |
![]() |
dashboardv0.2.0 |
Adds Phoenix LiveDashboard, as phx.new would have generated it | README NEED DESIGN CHANGELOG |
This table is generated from the catalog (./wb.sh catalog --json --brief | python3 assets/readme/build.py), so it says what ./wb.sh catalog says.
There is one kind of cartridge. These four words describe facts about some of them, not different types.
- Base cartridges are the capabilities
phx.newdecides when it generates a project, the ones behind its--no-ecto,--no-maileror--no-htmlflags. A project born with them shows them as inserted. One born without (./wb.sh new --no-mailer) can add it later with./wb.sh add mailer, and gets whatphx.newitself would have generated: the workbench generates the project twice, with and without the flag, at the project's own Phoenix version, and merges the difference into your code. - A collection is a cartridge whose installer inserts other cartridges. Each member still goes in as its own commit, so it can be ejected alone.
- An archived box is no longer offered for new projects but stays on the shelf with its papers, because the reasoning in them outlives the cartridge.
./wb.sh add --archived NAMEstill inserts it. - A pending box is the opposite case: it is on the shelf to be read, and no installer exists yet. The console shows these as Not done, and says which of two stages each one is at. A box is designed when its
DESIGN.mdis written and the installer is what is missing, and identified when its need is written and its design is still to do.
| Pending box | Stage | The need it answers | Papers |
|---|---|---|---|
| specdd | designed | You are handing the project to a coding agent, and the prompt is the only place it learns what it may touch. | README NEED DESIGN |
| stripe | identified | Your users should be able to pay — not done yet. | README NEED |
| security_review | identified | You have to be able to say, point by point, how your application answers the OWASP Top 10. | README NEED |
| machine_learning | identified | You want a model's answer inside your application, without running a second service in another language beside it. | README NEED |
| seo_aeo | identified | Your pages should be found, and quoted correctly, by a search engine and by an assistant answering someone's question. | README NEED |
| browser_tests | identified | You need to know the page works in a real browser, not only that the server answered. | README NEED |
| message_broker | identified | Two parts of your system have to talk to each other without waiting for each other. | README NEED |
| event_stream | identified | You need a record of events that several consumers can read again, each at its own pace. | README NEED |
The console is the workbench in the browser: a Phoenix LiveView application that shows the configured workspace and runs wb.sh for you. Everything it does is a wb.sh command, and it always shows which one, so using the console also teaches the CLI.
./wb.sh console # http://localhost:4100, its output in this terminal; Ctrl+C takes it down
./wb.sh console up # the same, left running (./wb.sh console logs, ./wb.sh console down)
./wb.sh console dev # with code reloading, for working on the console itselfIt runs as a container on an image of its own, built on the workbench's, with Docker's socket and the workbench directory mounted, and it listens on 127.0.0.1 only.
The page has four areas. Three are in the screenshot at the top of this document: the band across the top (what the workbench is doing right now, idle or the job that is running; the clock; the light or dark ground; the gear that opens the settings), the rail on the left, and the tabs with their screens on the right. The fourth is a jobs tray that slides up from the bottom with the output of the last command.
The screenshots in this chapter come from one session on a real workspace: a project created, dev brought up, and five cartridges inserted (health_probe, credo, changelog, exdoc, and db_admin with pgAdmin).
The rail is the state of the workspace at a glance. It stays in view on every tab. From top to bottom:
- Workspace. The project's name and the directory it lives in. The two squares move the rail to the other side, and its edge drags to resize it.
- Deployments. The three deployments, which one is up, and the buttons to bring each up, stop it or take it down.
- Containers. Every container of the deployment that is up, with its health, its logs and a restart.
- Services, doors & pages. Everything you can open in a browser. A service is a published container (the app, pgAdmin). A door is a route a cartridge opened in the app (
/health/live, the dev mailbox, LiveDashboard). A page is something the project generates on disk, like the ExDoc site. A door or a page that depends on a file has a second row for it: a mark that says whether the file is missing, up to date or behind what it is made from, and a button that runs the command that writes it (mix docs). Some doors are for a program and not for a browser, like the MCP server mishka_chelekom forwards: their address is shown and not linked. Each one names the cartridge it comes from, and the bell calls every door once to check that it answers. - Cartridges. What the project carries. A cartridge inserted by
addshows its commit, which is whatejectreverts. A base cartridge shows that it came with the project. - Git. Whether the tree is clean, the last commit, and who the workbench signs commits as.
Every section folds. A button that cannot be used is still shown, disabled, and its tooltip says why.
The small plates on the rail are a notation the whole console uses, on the Deploy, Logs, Terminal and Docker screens too. A plate is read in four parts:
- The drawing says what kind of address it is. A service published on the host opens in the browser. A service inside the pod only has a port and no way in from the host. A door is a route of the application that a cartridge opened. A page is files a tool of the project wrote on disk.
- The mark is on the second row, which a plate has only when it depends on a file. Hollow and grey means the file is missing: nothing has written it yet. Full and green means up to date: it was written after everything it is made from. Half and amber means behind: a source changed since, or the address a door's file carries moved. Beside the mark is the command that writes the file, as a button.
- The colour says what a service is for, in seven roles: compute runs the project's code, database keeps its data, devtools are for whoever develops, observability watches, balancer stands in front of the replicas, job runs once and ends, and network carries the traffic, and is also the colour of a service whose role the console does not know.
- The dot says where a cartridge is: full when the project carries it, hollow when it is on the shelf. Either way, the name opens its box.
A cartridge's service gets its colour from the role the cartridge declares, not from its name, so a service the console has never heard of is still drawn correctly.
The first tab. It has three cards: one to create the project, one to run it and one to delete it.
The card shows what config.conf says the next project will get: the workspace, the stack and the Phoenix installer. Each gear opens that setting in the workbench's drawer, which Settings describes. Below them are the flags of mix phx.new, drawn as what they are, base cartridges: untick mailer and the project is born without Swoosh, which is --no-mailer.
The line at the bottom is the exact command the button runs, and it changes as you tick. Create project generates a stock Phoenix project, adds the few things the workspace needs to boot it (the endpoint bound to 0.0.0.0, a .env file), bakes the three compose files and makes the first commit. It takes about a minute once the image exists.
From a shell:
./wb.sh new --name "Lorem Ipsum" [any mix phx.new flag]
One row per deployment. Each row shows whether its compose file is baked (the eye opens the file under the row), whether it is up, and the services the file declares with the address each one answers on. With the deployment up, every service also shows what Docker says of it.
Pick a row and use the buttons under the table: Up, Stop (containers kept, for a fast restart) and Down (containers removed, data volumes kept). Bake writes the compose file again and Build rebuilds the image without deploying. The scaled row takes its two options here: the number of replicas and whether nginx stands in front. It also warns that without the clustering cartridge the replicas run isolated, which is a valid deployment for a stateless application but not a cluster.
One deployment is up at a time: bringing another up takes the current one down first.
From a shell:
./wb.sh up [--deploy dev|prod|scaled], thenstop,down,bake,build
Deletes every file of the project in the workspace, with its containers, images and volumes, the database's data included. It cannot be undone, so the job waits in the Jobs tab until you confirm it.
From a shell:
./wb.sh delete
Every action in the console that runs wb.sh becomes a job: one command, run one at a time, with its output streamed live and its exit code and duration kept. This tab is the list, newest first, and clicking a job unfolds what it printed, in the colours mix, git and Docker gave it.
The line at the bottom is wb.sh itself: type any command (mix test, add credo, up --deploy prod), with history on the arrows and Tab completing commands, cartridge names and options from the catalog.
The three jobs in the screenshot were typed there: the docs build, Credo and the test suite, each run inside the app container.
The logs of the deployment that is up, all services merged and each line tagged with the one that wrote it. This is docker compose logs --follow with controls: the chips at the top show or hide a service, and the bar at the bottom filters by level, searches the lines, pauses the following and toggles timestamps.
From a shell:
./wb.sh logs [SERVICE...]
A shell on a running container. Pick the container at the top (the app, the database, any service a cartridge brought), pick bash or iex --remsh, and open a session.
IEx attaches to the node that is serving the application, so what you evaluate happens on the live system: the query in the screenshot runs through the app's own Repo. The terminal is line-oriented (it sends a line and prints what comes back), with history on the arrows, Ctrl+C to interrupt and Ctrl+L to clear.
From a shell:
./wb.sh iex,./wb.sh bash [SERVICE]
What the project is, read from the project itself: its git history, its mix.exs, its files. The tab has seven papers.
How the project was created, reconstructed from its first commit. It shows the stack in its Dockerfile.local, the Phoenix installer version and the exact mix phx.new command, then every flag of phx.new with the installer's own description, whether this project used it, and the base cartridge that owns it. A teammate who joins later can see how the project started without asking anyone.
The git log. An insert commit carries the badge of its cartridge, which opens its box. Click a commit to see its files, and a file to see its diff, with syntax highlighting. In the screenshot, the three lines health_probe added to the endpoint.
The dependencies of mix.exs as a table: the requirement, the version locked in mix.lock, and who brought the package (the project at birth, or a cartridge). The button at the top right asks hex.pm for each package's latest version, release date and downloads, so an outdated dependency is visible at a glance.
The project's .env, the file its compose files load. Secrets are masked before they leave the server.
The project's own README and its changelog, each rendered as a paper, the changelog with an outline to jump between versions. A stock Phoenix project has no changelog, so that paper is shown disabled until there is a file. In this session it appeared when the changelog cartridge was inserted.
What changed since the last commit, as diffs, with a message field and two buttons: Commit and Discard. add and eject need a clean tree, because a cartridge's commit must contain the cartridge and nothing else. This paper is where you get one.
From a shell:
./wb.sh commit [MESSAGE],./wb.sh discard
The shelf, live. The ribbon splits it in four: Inserted (what this project carries), On the shelf (what it can take), Not done (pending) and Archived. It can be seen as covers or as a list. Clicking a box takes it in hand and opens it on four screens.
The front of the box: the cover, the one-line summary, and the NEED.md with its Before, After and Not for. Below, the specifications: the Mix task that installs it, the doors it opens, the packages it adds with their versions.
The cartridge's papers, rendered: README, DESIGN and CHANGELOG. This is where the knowledge base is read. It works the same before inserting, to decide whether you want the cartridge, and long after, to remember how the thing it installed works.
The cartridge's options as a form, generated from its manifest, each with its documentation. An option that does not apply to this project is shown disabled with the reason: in the screenshot, coverage's --md-report needs the test_doubles cartridge and --githook needs precommit, and each badge opens the box that is missing. As on the New Project card, the command line follows the form, and Insert cartridge runs it as a job whose output lands on this same screen.
Once inserted, the button becomes Eject and the options show what the cartridge went in with.
From a shell:
./wb.sh add coverage --html-theme exdoc-ish,./wb.sh eject health_probe
What the cartridge wrote in this project, read from its insert commit: every file, as a diff. The Manual explains the idea and this screen shows the code that implements it, in your project, with your module names.
Docker as the workbench sees it: Containers, Images, Volumes, Networks and Events. Each list can be scoped to this workspace or to the whole daemon, which is how leftovers from old workspaces are found. Containers show live CPU and memory, and the other lists offer ./wb.sh prune for what nothing uses any more.
The last stop of the tour is not a tab. The gear on the band opens the workbench's drawer, with three tabs of its own. Its Config tab is config.conf as a form: the same settings as in Configuration, each with the file's own comment as its help. Saving runs ./wb.sh config set, which rewrites the values in place and keeps the comments.
Three of those settings are versions of things published somewhere else, and a version typed from memory is how a build fails ten minutes in. Each of the three has a refresh button beside it that asks the source and turns the field into a list of what really exists. None of them chooses for you, and config.conf keeps your choice and no copy of the lists.
The stack (DOCKER_IMAGE) asks Docker Hub for the hexpm/elixir images the workbench can build on: Debian slim, no release candidates, grouped by Elixir version. The problem it solves is one of combination. Elixir, Erlang and Debian are three versions, but only certain triples were ever published as an image. Picking an image sets the three fields below it at once, and changing one of the three by hand looks the image up again and says whether it is published.
From a shell:
./wb.sh stacks,./wb.sh stacks use TAG
The Phoenix installer (PHX_NEW_VERSION) asks hex for the phx_new releases and the Elixir each one requires, grouped by that requirement, with the Elixir of your stack written under the field. The problem it solves is compatibility: you see which installers your stack can run before anything is generated, and ./wb.sh new refuses one it cannot run. The first entry of the list leaves the field empty, which means the newest release that runs on the stack.
From a shell:
./wb.sh new --phx-new VERSION
Node (NODE_VERSION) asks two sources. Node's own release schedule says where each major stands (current, LTS, maintenance or end of life, and until when), and NodeSource says whether that major can be installed in the image at all. The problem it solves is picking a major that is both supported and installable: in the list below, Node 19 is marked as not on NodeSource.
The workbench itself never runs Node, and Phoenix ships esbuild and Tailwind as Elixir packages, so most projects can leave this field alone. It matters only when a project takes packages from npm. Today one cartridge option does: ash's --api typescript (ash_typescript), the one a React or any other TypeScript front end is built on.
In the three screenshots the list is drawn open under its field and the button that asks is outlined.
The Interface tab sets how the console looks. Unlike Config, what you set here belongs to your browser: it is kept in localStorage, never in config.conf, so it survives a restart or a rebuild of the console and does not travel to another browser or machine. It has four parts, each with a live sample beside its controls.
Overlay arranges the frame: the band on top or at the bottom, the rail on the left, on the right or hidden, and the ground light, dark or whatever the system says.
Terminal and Code are the two surfaces that carry colour: the output of commands and logs, and the source in diffs. Each takes a theme from a file in console/themes/ (<name>.terminal.json or <name>.code.json), written with the same keys VS Code themes use. Dropping a file there and reloading the page adds it to the list, with no image to rebuild. Every colour can also be adjusted on its own, beside a sample that shows it as it lands.
| The terminal's theme | The code's theme |
|---|---|
![]() |
![]() |
Credits lists every typeface and every theme the console draws with: whose it is, where it comes from and under which licence.
The third tab of the drawer, Manual, holds the workbench's own README and CHANGELOG.
Using cartridges is half of the knowledge base. The other half is writing your own: when you work out how to set something up in Phoenix, you can keep it as a cartridge instead of as a note or a gist.
There is no generator and no form in the console for creating a cartridge yet. You write one by hand, in Elixir, in igniter/. Cartridges are part of the workbench's own repository, so your shelf, or your team's, is a fork of it: the cartridges you add travel with the fork, and the ones here keep arriving from upstream. The steps:
- Write the need.
NEED.md: one sentence, then Before, After, Not for. If you cannot write it, the cartridge is not clear yet. - Do the research and write it down.
DESIGN.md: the problem, the sources, the decisions and the alternatives they beat. How to write one. - Write the installer. A module with
use WorkbenchIgniter.Feature(the manifest and theinstall/1function) and a small Mix task shell, inigniter/lib/workbench_igniter/features/<name>/. Templates go inpriv/features/<name>/. The anatomy and the manifest. - Make it answer for itself.
installed?/1reads the same mark the installer checks, so inserting twice changes nothing. - Register it in
WorkbenchIgniter.Features, and it appears in the catalog and on the console's shelf. - Test it with
Igniter.Testagainst a generated Phoenix project, then for real:./wb.sh add <name>on a workspace. - Write the README and the CHANGELOG, and optionally give it a cover.
- Ask whether it needs a figure. If the cartridge adds a path at runtime (a route, a container, a file something else reads), its README draws it; if a decision turns on who acts in whose turn, its DESIGN does. Most cartridges need none. The figures are drawn by one script.
The full checklist is Adding a feature. credo is the smallest complete example and health_probe the reference.
A box is optional: a cartridge works without one and shows a placeholder, as k6 and monitoring do above. But the part of cartridge authoring that is already assisted is this one.
The covers are generated with an image model, and the repository keeps the whole process rather than only the results. It has three parts:
- A Claude Code skill,
.claude/skills/cartridge-covers/. Open the repository in Claude Code and ask for a cover. The skill reads the cartridge'sNEED.mdand README, proposes six concepts in six different visual eras, and writes the chosen one as a prompt file inassets/covers/<name>/. - You. The skill does not generate images. You take the prompt to an image generator, and bring the result back. The skill then checks it against the prompt and the rules every cover follows.
- A script,
assets/covers/covers.py, for everything that must be exact: fitting the art to the 5:7 face, laying the workbench's banner over it and stamping the seal.
| 1. The hero, generated | 2. Padded to the face | 3. Margins painted, second turn | 4. Cut, banner and seal |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
The box has a back too, and it is made the other way round: composed, not generated. A back is mostly small text — a blurb, a list of features, requirements, a legal line — and small text is what an image model gets wrong. So the generator makes only the plate: the front's material with nothing written on it, a faint device in the middle and a darker band at the foot. Everything else is set by the script:
- The copy is written by hand into
assets/covers/<name>/back/copy.md: a headline, a blurb drawn from the cartridge'sNEED.md, the decisions of itsDESIGN.mdas features, what it was verified on, and the command that inserts it. - The screenshots are real ones, of what the cartridge installs in a project: a page it adds, its report, its output in a terminal. "Actual screens shown" is on every back, and it is true.
covers.py back <name>lays the frames, the screenshots, the copy, the workbench's name, the cartridge's version read off its changelog, and the seal over the plate.
| 1. The plate, generated | 2. A real screen | 3. Composed and sealed |
|---|---|---|
![]() |
![]() |
![]() |
The record of every cover and every back, with what each attempt taught, is assets/covers/README.md.
For the reader who wants the decisions rather than the tour:
- Cartridges patch the AST, and are one commit each. Igniter installers compose and stay idempotent where
sedscripts do not, and a commit per insert makes each one readable, revertible and attributable. igniter/README.md. - A
phx.newcapability can be added after the fact.WorkbenchIgniter.PhxDeltagenerates the project twice, with and without a flag, at the project's own installer version, and three-way merges the difference. It is howadd ectooradd htmlwork on a project born without them. ecto/DESIGN.md. - One compose per workspace, and a pod inside it. A project owns its orchestration, so several run at once, and the shared network namespace keeps Phoenix's
localhostconfiguration untouched. - A compose service belongs to its cartridge. Nothing outside a cartridge names its service. The same insert serves dev, prod and scaled because the cartridge declares the service and the bake writes the topology.
- The project owes the workbench nothing. No route, file or marker exists in a project only for the workbench to read. The console reads what is already there: the source, Docker and git. A project can leave at any time by simply not using
wb.sh. - The console does not reimplement the workbench. It runs
wb.shas jobs and reads its JSON contracts. What only the project can answer is asked of one long-lived BEAM with the project loaded. console/README.md. - It is tested the way it is used. Each cartridge has a test that runs its installer on a generated Phoenix project, and runs it twice to show the second run changes nothing. The base cartridges get a harder one: a project born bare and grown cartridge by cartridge has to equal, file by file, the project born whole. The compose files are compared byte for byte with golden copies. That is close to 700 tests in
igniter/and close to 400 inconsole/, and every push also runs ShellCheck, the formatter, Credo in strict mode and Dialyzer. - The record is the CHANGELOG: what was done and why, decision by decision. CHANGELOG.md.
None of these was free. What each decision costs:
| Decision | What it costs |
|---|---|
| The pod: every service in one network namespace | The application has to listen on 0.0.0.0, because a published port arrives on the pod's interface and never on loopback. It is the one line of a stock project's configuration the workbench changes. And the pod does not scale: replicas sharing a namespace would share one address and one port, so the scaled deployment is a second topology, on a bridge network, with the database reached by name. |
| One kind of cartridge: a collection is a cartridge too | A collection leaves no commit of its own and may not re-expose a member's option. Whoever wants a member configured differently inserts that member directly. |
| The resident: a second BEAM with the project loaded | It is compiled into a volume of its own: a second incremental compile of whatever an insert changed, a build's worth of disk per workspace, and a console bound to the workspace it was started for. Pointing it at another means starting it again. |
| One commit per cartridge, and ejecting is reverting it | add and eject need a clean tree, and an option cannot be changed in place: the cartridge is ejected and inserted again. |
| One compose per workspace, its ports written at creation | A hand edit to a compose file lasts until the next bake, unless it goes in the override file. Changing a port is editing that file. |
| Base cartridges by difference: generate twice and merge | The project has to remember the Phoenix installer it was born with, and the workbench has to be able to run that version again. |
wb.sh in bash, the one implementation of every command |
About 3,500 lines of bash. It is what lets the host need nothing but Docker and a shell. |
| The project owes the workbench nothing | The workbench has to work everything out by reading: the source, Docker and git. |
| Cartridges live in the workbench's repository | A shelf of your own, or your team's, is a fork. |
The implementation was AI-assisted. The architecture and the decisions are the author's, and the CHANGELOG is where each one was argued.
Every push runs the checks in .github/workflows/ci.yml. To run them before pushing:
shellcheck -x wb.sh scripts/entrypoint.sh
cd igniter && mix format --check-formatted && mix credo --strict && mix dialyzer && mix test
cd console && mix assets.build && mix format --check-formatted && mix credo --strict && mix dialyzer --force-check && mix testEach package pins its Erlang and Elixir in its own .tool-versions. The first mix dialyzer builds its PLT, which takes a few minutes. The console takes --force-check because it carries igniter/ as a path dependency, and Dialyzer's cache does not notice that one changing.
CI also checks that the two tables of cartridges in this README say what the catalog says. After adding a cartridge, or changing one's version or summary, write them again:
./wb.sh catalog --json --brief | python3 assets/readme/build.pyTo pull private images from the GitHub registry, log Docker in with a classic personal access token: ./wb.sh login [GITHUB_USER] [ACCESS_TOKEN].
This software is released under the MIT license.
Permission is granted to use, copy, modify, and distribute the code in both commercial and non-commercial projects. It only requires that the copyright notice and permission statement be maintained in all copies. No warranties are provided and the authors bear no liability.
Copyright © 2024-2026 José Luis Pamplona Stoever.

























































