Build, deploy and release
A product reaches production through three commands that do three different jobs. product:build compiles the product into deployable artifacts, product:deploy runs those artifacts on the machine you are on, and product:release publishes a version of the product to the outside world. Two of them form a chain. The third stands on its own.
Table of contents
Three verbs
Build reads the product and every feature it declares, merges their production environment and Compose definitions into two files under docker/, and builds the production image on the local Docker daemon. Deploy takes those two files, fills in the secrets, and starts the production containers from that image, on the same machine. Release bumps the version, tags the commit, and pushes the product to the forge, to PyPI and to Docker Hub. It does not start anything. Build and then deploy is how a product goes live on a host. Release is how a version of the product gets a name that other machines and other people can refer to.
Side by side
product:build |
product:deploy |
product:release |
|
|---|---|---|---|
| In one word | Compile | Run | Publish |
| Reads | The product’s pyproject.toml, its docker/ directory and the docker/ directory of every declared feature |
docker/.env.deploy.example and docker/docker-compose.deploy.yml, both written by build |
The product’s git repository and pyproject.toml |
| Writes | docker/.env.deploy.example, docker/docker-compose.deploy.yml, docker/features/, and the image <product>:<version> on the local daemon |
docker/.env.deploy, then the containers of the <product>_deploy Compose project |
A version bump committed in pyproject.toml, and a git tag |
| Sends outside the machine | Nothing | Nothing | The commit, the tag and a release page to the forge, the package to PyPI, the image to Docker Hub |
| Needs | Every production feature pinned to a version that is on PyPI or tagged on the forge | A previous build, and the <SET> values the first time |
Every feature pinned, lint and tests passing, credentials for the three channels |
| Undo | Run it again | product:deploy --down |
None. A PyPI version is permanent |
| Typical place | The production host, or your workspace to check that the image builds | The production host | Your workspace |
The chain, build then deploy
latest.product:build, compile
product:build turns the product and its features into something Docker can run without the workspace. It runs the pre-flight checks first (the feature selection is satisfiable, the contracts do not collide, every production feature is pinned and reachable) and then does three things.
- Merges the production environment. Each feature’s
.env.prod.exampleand declared configuration, then the product’s choices for those features, then the product’s own.env.prod.example, intodocker/.env.deploy.example. Secrets stay as<SET>placeholders. - Merges the Compose definitions. The product’s
docker-compose.prod.ymland every feature’s, with the feature Dockerfiles and assets copied intodocker/features/, intodocker/docker-compose.deploy.yml. - Builds the production image from
docker/Dockerfile.<product>.prod, with the workspace as build context, and tags it<product>:<version>and<product>:latest. The version is the one inpyproject.toml. The image installs every feature from PyPI at its pinned version, or from its git tag when PyPI does not serve it, and never from your working copies.--no-imageskips this step.
The two files are deployment artifacts and are versioned with the product, so a clean clone carries them. The image lives on the Docker daemon of the machine that ran the build. See product:build for the merge rules and Env files for where each variable comes from.
product:deploy, run
product:deploy starts the product in production mode on the machine where you run it. It refuses to start without the two files from build. It creates docker/.env.deploy from the template the first time, asks for every <SET> value with a sensible suggestion, generates SECRET_KEY, and on later deploys syncs the file with the template while keeping what you filled in. It checks that no other container holds the ports, marks the product as deployed with SPLENT_ENV=prod, and runs
docker compose -p <product>_deploy -f docker-compose.deploy.yml --env-file .env.deploy up -d
The web service in that Compose file names image: <product>:latest, so deploy runs exactly the image that build made and never rebuilds it on its own. When the app answers, deploy prints its URL. product:deploy --down stops the same Compose project with the same env file. See product:deploy for the sync rules and for what the CLI does while production is deployed.
Putting a change into production
To put a change into production, a new pinned feature version, a new environment variable, a change in the product itself, run build again and then deploy again. Build rewrites the template and the image. Deploy syncs .env.deploy without losing your secrets and restarts the containers on the new image. product:derive --prod runs the two in one command.
Release stands apart
product:release gives a version of the product a name and publishes it. It refuses to run while any feature is editable, because a release must be reproducible from its pinned versions alone. Then it verifies the three channels, runs lint and the product’s tests, bumps the version in pyproject.toml, commits, uploads the package to PyPI, pushes the commit and the tag v<version> to the forge and creates the release page there, and finally builds the production image and pushes it to Docker Hub as <user>/<product>:<version> and <user>/<product>:latest. That image is the same build as product:build makes, the production Dockerfile with the workspace as context, tagged for Docker Hub instead of for the local daemon.
If a later step fails, release:resume finishes the channels that are missing without bumping the version again. See product:release for the gates and the credentials.
Release does not need a build to have happened, and build does not need a release. What ties them is the version in pyproject.toml. Release is what moves it, and build reads it to tag the local image, so a host that builds after a release gets an image tagged with the released version. Release also runs from your workspace, where the git repository and the credentials are, while build and deploy run wherever the product is served.
Nothing in this chain is automatic. A release does not deploy, and a deploy does not release. A host that should run a released version clones the product repository at its tag, builds and deploys.
Which one do I run
| You want to | Run |
|---|---|
| Put the product, or a change to it, into production on this machine | product:build, then product:deploy |
| The same, in one command | product:derive --prod |
| Check that the production image builds, without deploying | product:build |
| Regenerate the deploy files without the slow image build | product:build --no-image |
| Stop production | product:deploy --down |
| Name a version of the product and publish it to the forge, PyPI and Docker Hub | product:release |
| Finish a release that stopped halfway | release:resume product |
| Release a feature, not the product | feature:release, see Editable and pinned features |
See also
product:build,product:deployandproduct:release. The reference for each commandproduct:derive. Build and deploy in one command with--prodrelease:resume. Finish an interrupted release, Docker image included- Editable and pinned features. Why production accepts pinned features only
- Env files. Where each variable of
.env.deploy.examplecomes from - Features that run a server. How a feature’s own containers reach the deploy stack
- Tutorial 5, Release and deploy. The whole flow, step by step