JVKdouk/RedKite: Blue-green Docker deployment from one config file, over ssh. No engine, no agent, no dependencies. -

github.com · 2026-10-01 · by Long Nguyen ·github
Blue-green Docker deployment from one config file, over ssh. No engine, no agent, no dependencies. - JVKdouk/RedKite

JVKdouk/RedKite

Blue-green Docker deployment from one config file, over ssh. No engine, no agent, no dependencies. - JVKdouk/RedKite

1 stars · 0 forks

README

Blue-green Docker deployment from one config file, over ssh. No engine, no agent, no dependencies.

Describe a deployment once. Redkite derives every container name, IP address, volume, cache key, nginx upstream and location block from it, then builds your images on the machine that runs them and swaps traffic over with a health check and an automatic revert. A deploy is git, a Dockerfile and the docker CLI, driven over one ssh connection. There is nothing to install on the server and nothing running between deploys. Contents

Quick start Features Requirements Configuration Hooks Plugins Verifying a build CLI GitHub Actions How a deploy runs Design Contributing Security License

Quick start npm install redkite Add redkite.config.ts at the root of your project: import { defineDeployment, nextApp, nodeApp, redis } from "redkite";

export default defineDeployment({ project: "acme",

services: [redis()],

apps: [ { name: "web", repo: "git@github.com:acme/web.git", route: "/", port: 3000, build: nextApp(), health: { path: "/api/health", expect: (body) => body.status === "ok" }, }, { name: "api", repo: "git@github.com:acme/api.git", route: "/api/", port: 3001, build: nodeApp({ steps: ["yarn build"], output: "/app/dist", entrypoint: ["node", "/app/index.js"], }), health: { path: "/health", expect: (body) => body.status === "up" }, }, ], }); Then: npx redkite plan production # what it will do, no host needed npx redkite deploy production # build, swap, health check, revert on failure Nothing in that config names an IP address, a container, a network, a cache key, a retired- prefix, or an nginx directive. Adding a third app is six lines. Features

Blue-green by default. The running container moves to a retired address without being stopped, so it keeps answering while the new one starts. One unhealthy app reverts all of them. Derived topology. Container names, addresses, volumes, cache keys, nginx upstreams and location blocks are functions of the app list, so two of them cannot collide and adding an app cannot renumber another. Builds on the deploy host. The image is created inside the daemon that will run it, so there is no export, no tarball and no transfer. Nothing crosses the wire. The host clones your repositories itself over a forwarded agent. A warm deploy sends commands and secrets, nothing else. Secrets stay out of the image. The environment file and every credential arrive as BuildKit --secret mounts, so they are in neither a layer nor docker history. An extensible pipeline. Redkite's own four steps sit at ordinary points that your config can hook around or replace outright. Zero dependencies. One package. Node reads the TypeScript config itself.

Requirements

Where Needs

Your machine Node 22.18 or newer, git, ssh, an ssh agent with a key that can reach your repositories

The deploy host docker with BuildKit, git, ssh access

Node 22.18 is the version that reads TypeScript without a loader, which is what lets redkite ship with nothing in dependencies. If your config needs more than Node resolves on its own, a tsconfig path or a ./thing.js specifier pointing at a ./thing.ts file, redkite uses your project's own tsx when you have one. Name the config redkite.config.mts if your package.json has no "type": "module": a .ts file in a CommonJS package is CommonJS, where import is not legal. Redkite says so if you get it wrong. .js and .mjs work too, and --config overrides the search, which otherwise walks up from where you ran it to the root of the project. Configuration defineDeployment validates at load, so a duplicate name, two apps on one route, or a malformed hook point is a config that fails rather than a deploy that stops half way through. Environments One file each, beside the deployment, named redkite.<environment>.config.ts: // redkite.production.config.ts import { defineEnvironment } from "redkite";

export default defineEnvironment({ branch: "main", subnet: "10.20.0", publicPort: 80, host: { bastion: "deploy@acme.example" }, }); Redkite finds them by name, so there is no list to keep in step, and anything beside the deployment whose name starts with redkite and is a module counts: redkite.staging.ts and redkite-staging.ts name the same environment as redkite.staging.config.ts does. One that was meant to be an environment and is named slightly differently is refused rather than passed over as though it were not there. The name has to be lower case, because it becomes part of an image tag and a tag cannot hold a capital. redkite.config.ts has no environments key at all: a second place to put a set of them is a second place for them to disagree. A config that declares one does not compile. A deployment with only one environment can carry it instead, as environment: export default defineDeployment({ project: "acme", environment: { branch: "main", subnet: "10.20.0", publicPort: 80 }, // ... }); That is an override, not a default: it answers for whatever name the command line asks for, and wins over any file that disagrees. Two environments means two files. Selected on the command line and threaded into every derived name.

Field Meaning

branch The git ref each app is built from

subnet First three octets. Redkite allocates the fourth

publicPort The only port anybody outside ever types

host.bastion user@address of the deploy host. Absent means this machine

extraHosts Hostname to address, added to every container beside the derived ones

buildOn "host" by default. "local" compiles here and ships the image

secrets App name to the items this environment reads, after the app's own

files App name to container path to item, laid over the app's own files

steps Steps for this environment alone, replacing the deployment's at the same point

Images are built on the deploy host, which is where they are needed and costs nothing to move them. A host too small to compile on can be told otherwise: production: { buildOn: "local", branch: "main", subnet: "10.0.0", publicPort: 80 } The checkout, the Dockerfile and the build then happen on this machine, and the finished image is streamed into docker load on the host down the connection that is already open. Nothing is written to a disk at either end. The skip check asks the host rather than this machine, so an image compiled here before but never sent is still sent. It is ignored when the deploy host is this machine, since there would be nothing to move. extraHosts is for something the apps must resolve that redkite does not run: a managed database, a legacy service, anything whose address is the thing that differs between staging and production. Secrets that differ between environments Staging and production usually read the same variables from different items. The item is named in the environment's file, beside everything else that differs, keyed by the app it belongs to: // redkite.production.config.ts export default defineEnvironment({ branch: "main", subnet: "10.20.0", publicPort: 80, host: { bastion: "deploy@acme.example" },

secrets: { frontend: bitwarden.item("..."), backend: bitwarden.item("..."), },

files: { backend: { "/app/service-account.json": bitwarden.item("...") }, }, }); An environment's refs are read after the app's own, and refs merge in order with the later key winning. So the deployment can hold what every environment shares, or nothing at all, and each environment lays its own item over it. files merge by path: a path the environment names replaces the app's, and every other file stays. An environment file does not import the deployment, so an app name in it cannot be checked when it compiles. One that names no app is refused when the run starts, before a connection or a vault is opened, and plan refuses it too. The ids are pointers, not credentials. What keeps staging from reading production is the token: give each environment its own BW_KEY in .env.<environment>.deploy, or its own GitHub environment in CI, scoped in Secrets Manager to that environment's project. Steps that differ between environments A migration that reaches a managed database from the host in production and a service on the deployment network in staging is the same step with different settings. The deployment holds the one most environments share, and an environment that differs puts its own at the same point: // redkite.config.ts steps: [migrate({ app: "backend", command: "yarn db:migrate" })],

// redkite.staging.config.ts steps: [migrate({ app: "backend", command: "yarn db:migrate", network: "deployment" })], A step at a point the deployment already fills replaces it for that environment, where it stood. It is the rule redkite's own four already follow, applied one level down. A step only the environment has runs ahead of the deployment's at the same slot, the way a plugin's does. Two steps at one point in one environment file are refused when the file is defined, and so is a step landing on a point a plugin already fills. plan prints the pipeline the environment will actually run. extraHosts: { "db.internal": "10.55.0.250" }, A name the deployment already resolves, an app's container or a service alias, is refused rather than silently overridden. Redirecting one of those would send its traffic somewhere else and the deploy would still look like it worked. Where the files live By default redkite.config.ts sits at the root of the project with the environment files beside it, and a deploy run from anywhere inside walks up to find them. package.json is where a repository says otherwise: { "redkite": { "directory": "deploy" } } The deployment does not have to be one of them. A repository that keeps redkite.config.ts at its root and the environment files together under deploy/ works the same way: the directory is read for environments whether or not the deployment turned out to be in it. Naming a directory that is not there is still refused, and one environment defined in both places is too. Then deploy/redkite.config.ts and deploy/redkite.production.config.ts. If that directory holds no config, redkite stops and says so rather than carrying on up the tree: naming a directory and not putting the files there is a mistake, not a hint. An environment that lives somewhere the naming convention would not find it can be named outright, at whatever path it is at: { "redkite": { "environments": { "production": "./infra/live.ts", "staging": "./infra/staging.ts" } } } Paths are read against the package.json that names them. One that is not there is refused rather than silently skipped, and an environment named here that also sits beside the deployment is refused too: it comes from one place or the other. Apps

Field Meaning

name Becomes the container name, cache keys, volumes and nginx upstream

repo Clone URL, fetched over the forwarded agent rather than with a token

route The nginx location. / is the catch-all; /api/ has its prefix stripped

port The port the app listens on inside its container

build How the repository becomes an image. See presets below

dir Where the app sits in the repository, when it is not the whole of it

health Probed on the container itself, not through the proxy

secrets One ref or several, merged in order, handed to the container as its environment

files Container path to the item whose contents land there

volumes Volume name to container path, for state that outlives a deploy

What an app is built from Every cloned app takes the environment's branch unless it says otherwise. An app can name its own, and can pin instead of tracking: { name: "backend", repo: "…", branch: "release" } // tracks that branch { name: "worker", repo: "…", tag: "v1.2.3" } // pinned { name: "legacy", repo: "…", commit: "9f2b4c1" } // pinned At most one of the three, and a config naming two is refused: they are different commits and picking one is not redkite's to do. branch is looked up under refs/heads, tag under refs/tags and peeled, so an annotated tag answers with the commit it points at rather than with the tag object. A commit is taken as it is and checked that it is one. The failure says which kind was missing: "acme/backend has no tag v9". An environment can only say branch. A tag or a commit is a claim about one repository, and an environment spans every app in the deployment. Each app is cloned as a step of its own, ahead of its build. It says the repository and what it is fetching as it starts, and says them again with the commit it landed on when it ends, so the row keeps them after the step's progress has moved on: ✔ build (1m14s) ✔ Cloning backend: acme/backend staging -> 64ae9f1 (2s) ✔ Building backend: 64ae9f1 (1m12s)

A repository on GitHub is named by its path alone, and one anywhere else by its host and path, without the .git either way. A branch goes by its name alone, and a pin says which kind it is: tag v1.2.3, commit 9f2b4c1. A repository that cannot be reached, or a branch it does not have, fails that step rather than the build, and gets a file of its own in the crash log. An app built from a directory is read where it is rather than cloned, so it has no such step. Building from a directory An app names either a repository to clone or a directory already on this machine. A CI job that has already checked the code out is one; so is the copy you are editing. { name: "backend", path: "./services/backend", // instead of repo route: "/api/", port: 3001, // ... } The path is read against the deployment file, not against wherever the command was run, so a deploy from a workspace and one from the root build the same tree. Nothing is cloned, checked out or cleaned: what is on disk is what ships, and the branch an environment names is not consulted at all. The release is the content of the working tree, taken with git's own addressing over a scratch index. It covers what is committed, what is modified and what is untracked, and honours .gitignore, which is the same set the build reads. So an edit you never committed is a new release and gets built, and a change under an ignored node_modules is not and does not: unchanged already built at 7797c4c, nothing rebuilt uncommitted edit built from 9bba792, rebuilt untracked file built from 587974b, rebuilt ignored file already built at 9bba792, nothing rebuilt

Somewhere outside git there is nothing to say any of that, so the deployment says it instead: { name: "backend", path: "../checkout", include: ["src", "package.json", "yarn.lock"], } include names what ships, relative to path. It decides the release and the build context together: BuildKit is handed a .dockerignore that holds everything back and lets exactly these through, so a node_modules the release says nothing about is not uploaded either. Asked to build a directory git knows nothing about without one, redkite says so and shows the line to add rather than guessing or refusing outright. An include on a work tree is allowed too, and wins over .gitignore. That is how a repository holding several things is narrowed to the one being built. When the deploy host is another machine, an app built from a path forces the build to happen here and the image to be shipped, the same as --local. The source is on this machine, so the builder is too. Dependencies fetched over ssh A git+ssh entry in a manifest is resolved during the install, inside the build, long after the checkout on the host is finished. That needs three things the checkout does not, and redkite arranges all of them when this process holds an agent: openssh-client in the builder, the agent forwarded in as a --mount=type=ssh, and GIT_SSH_COMMAND set to accept a host on first sight. A run with no agent asks for none of it, because a mount with nothing behind it fails the build outright. Whether it was forwarded is part of what the image is tagged by, so an image built without one is never mistaken for an image built with one. Nothing else is needed: the same agent that lets the deploy host clone the repository is what the build reaches through. Build presets nodeApp and nextApp describe a two-stage build: a fat builder image with caches mounted, and a slim runtime image the compiled output is copied into. nodeApp({ builder: "22-alpine", runtime: "24-alpine", submodules: true, steps: ["yarn db:generate", "yarn build"], output: "/app/dist", carry: ["/app/.generated"], entrypoint: ["node", "/app/index.js"], }); The dependency install is not one of the steps. The preset copies package.json and yarn.lock ahead of the source and installs against those alone, so a commit that changes only source code reuses the layer. That is the difference between a deploy and a cold build. The package manager's download cache is a cache mount; node_modules is a layer. They are not interchangeable, because a cache mount is evicted on its own schedule and the layer that filled it is not: once the two disagree, the install is cached, the mount is empty, and the build runs against no dependencies at all. caches therefore defaults to the download caches only, and what a build needs on disk is written into the image. What a build is allowed not to produce A COPY whose source is missing fails the build. That is right for the output, which is the app itself, and wrong for a directory the repository may simply not have. A carry entry says which it is: carry: [ "/app/.generated", // must exist, or the build failed { path: "/app/public", optional: true }, // skipped when it is not there ] nextApp marks public optional and .next/static required, because static is what the server answers with for every chunk it built. A lockfile named in dependencies.files is optional too: whether one is missing is the package manager's to say, in its own words. nextApp takes standalone, which says whether next.config sets output: "standalone". It defaults to true. A standalone build ships the tree Next produced and runs it on node alone; without it the whole repository ships and next start resolves its own dependencies, which is a much larger image. nextApp({ standalone: false }) An app in a directory of its own A repository holding several apps names each one's directory: { name: "web", dir: "apps/web", build: nextApp(), / ... / } The whole repository is copied into the image, and dir is where the build steps and the shipped command run. Every /app path the build spec names is read against it, so a preset needs no change: output: "/app/dist" becomes /app/apps/web/dist. A Next standalone build traces from the workspace root, so the tree it emits holds apps/web/server.js rather than server.js. nextApp declares that with keepsLayout, and the runtime stage then starts the command at /app/apps/web and lands .next/static and public beside it. A build whose output flattens the app to the top of the tree, which is every nodeApp, leaves keepsLayout unset and nothing moves. The dependency install stays at the repository root. A workspace resolves one lockfile for every package in it, so the install has to see all of them, and node_modules is where that put it. An app with its own lockfile in a subdirectory is not covered by dir alone. Reaching the deploy host host: { bastion: "deploy@acme.example", hostKeys: "accept-new" } The host's key is checked before anything is handed to it. accept-new, the default, trusts it the first time and refuses it if the key ever changes after that: no setup, and nothing to intercept once the first deploy has happened. strict refuses any host not already in known_hosts, which is stronger and means putting the key there yourself. off checks nothing, for a host whose address is handed out again and again and only where nothing on the way to it can listen. ssh is never left able to ask a question, whic

source

github.com · 5 images · 108 links

Comments (0)

No comments yet