DEV Community

Cover image for CI/CD on Node: The Maddening Reality
João Victor Cardoso Kdouk
João Victor Cardoso Kdouk

Posted on

CI/CD on Node: The Maddening Reality

Ask a Node.js developer to name CI/CD tools and they'll likely say CircleCI, Jenkins, or GitHub Actions. Ask about SSH-based deployment tools you can run from your own machine, and the filter becomes too aggressive: Shipit (archived), and Flightplan (unmaintained). Add blue/green deployments to the requirements and the list comes back empty. How does the most widely used language in the world lack a maintained, Node-native way to deploy over SSH?

A year ago, we kept running into those questions, and we couldn't believe how few viable options existed for automating Node.js deployments. Our projects had many moving parts, every release was deployed by hand, and our only realistic choices were paid hosted services or tools that had been abandoned. This post walks through what we tried and how we ended up building our own deployment orchestrator, Redkite (and all the lessons we learned from it).

Shell Scripts: How Hard Can It Be?

Our first attempt was the obvious one: a shell script per project that connected over SSH, pulled the latest code, rebuilt the containers, and restarted them. For a single app on a single server, that's all you need, and for a while it was all we had.

Unfortunately, it didn't stay that simple. Each new project started as a copy of the last script, then picked up its own quirks, until no two deploy scripts behaved quite the same way. Secrets had to be updated on the server by hand before every release, a shiny little environment file laying bare on the server. Servers behind a bastion needed SSH configuration that lived on each engineer's machine rather than in the repository, so a deploy that worked for one person could fail for the next.

The bigger problem was what happened when something went wrong. Restarting containers meant downtime, so every release was a small outage. If a build failed halfway through, the script simply stopped and left the server in whatever state it had reached. Nothing checked that the new version was actually serving traffic, and there was no easy way back to the old one. Adding blue/green cutovers, health checks, and rollbacks in Bash was possible, but it meant writing a deployment tool in a language that is unsuited for it.

Dagger.io: Our Descent Into Madness

Once plain shell scripts stopped being enough, we went looking for something a little less flaky. Dagger looked like the right fit: actively maintained, widely used, and backed by a strong community. Well... I can now say it wasn't, and misjudging that cost us about one hundred hours of work.

Dagger is a programmable engine for running CI pipelines in containers. It is not a deployment orchestrator. It won't set up an SSH-based deployment for you, run a blue/green check, or manage Docker images on a remote host. We missed that distinction and built our entire deployment stack on top of Dagger's engine anyway.

By the end, we had rebuilt Dockerfile-style image builds inside Dagger, zero-downtime cutovers by swapping Nginx upstreams (also built inside Dagger), health checks, and a client for Bitwarden's secrets API. By the time we realized we were writing a deployment tool from scratch, the hours were already spent. And all of it ran through Dagger's strict type reflection, whose cryptic errors slowed development.

Did it work? Partly. Dagger turned out to be a resource hog, and its costs outweighed what it gave us. Every engineer had to run the Dagger engine alongside Docker just to complete a three-minute deployment, and a large share of that time went to Dagger's own overhead: syncing unnecessary files into the engine (we built from the remote repository anyway), its lack of real support for building on a remote server, and deleting and transferring images after every local build. The result was a slow, fragile deployment orchestrator.

It held up for a while, but it broke often: SSH agent forwarding failed, our Bitwarden CLI integration misbehaved, and the pipeline had no reliable view of the server's current state. It didn't take long to realize we could do it better and much more efficiently than we could ever hope to achieve with Dagger.

RedKite: Native Deployments for Node.js

Everything we learned building on top of Dagger went into RedKite: a deployment orchestrator written entirely in TypeScript, with zero npm dependencies and no services to install. Blue/green deployments work out of the box, builds can run locally or on the remote host, and SSH bastions and agent forwarding are supported natively. Add the package, write a configuration file for each environment, and Redkite handles the rest.

Install it as a dev dependency:

npm install --save-dev redkite
# or
yarn add --dev redkite
Enter fullscreen mode Exit fullscreen mode

The main configuration file, redkite.config.ts, describes the project, namely the apps to build, the services they depend on, and the proxy that routes between them.

// redkite.config.ts
import { bitwarden, defineDeployment, nextApp, nodeApp, redis } from "redkite";

export default defineDeployment({
  project: "my-example-project",
  plugins: [bitwarden({ secrets: true })],
  services: [redis()], // Supporting services RedKite runs alongside the apps
  steps: [], // Extra pipeline steps

  // Nginx sits in front of every app so blue/green cutovers are a config swap
  proxy: {
    logs: { access: true, errors: "error" },
  },

  // Each app builds into one container
  apps: [
    {
      name: "frontend",
      route: "/", // The proxy routes this path to the app
      port: 3000,
      repo: "git@github.com:example/my-example-project-frontend.git",
      health: { path: "/health", expect: (body) => body.status === "ok" },
      build: nextApp({
        builder: "24-alpine",
        runtime: "24-alpine",
        standalone: true,
      }),
    },
    {
      name: "backend",
      route: "/api/",
      port: 3001,
      repo: "git@github.com:example/my-example-project-backend.git",
      health: { path: "/health", expect: (body) => body.status === "up" },
      build: nodeApp({
        builder: "24-alpine",
        runtime: "24-alpine",
        submodules: true,
        steps: [],
        output: "/app/dist",
        entrypoint: ["node", "/app/dist/main.mjs"],
      }),
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

Each environment then gets its own file, which supplies the host, branch, secrets, and any steps specific to that environment:

// redkite.staging.config.ts
import { bitwarden, defineEnvironment, migrate } from "redkite";

const frontendEnv = bitwarden.item("secret-frontend-staging");
const backendEnv = bitwarden.item("secret-backend-staging");

export default defineEnvironment({
  host: {
    bastion: "user@host",
  },

  branch: "master",
  subnet: "172.30.0",
  publicPort: 4000, // Port Docker publishes on the host
  buildOn: "host", // "local" or "host"

  secrets: {
    frontend: [frontendEnv],
    backend: [backendEnv],
  },

  steps: [
    migrate({
      app: "backend",
      command: "npm run db:migrate",
      network: "deployment",
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

With both files at the root of your project, deploying is one command:

yarn redkite deploy staging
Enter fullscreen mode Exit fullscreen mode

Redkite resolves your secrets, tunnels into the server through the bastion, builds each app locally or on the host as configured, and switches traffic to the new containers once their health checks pass.

More examples and settings are available in the project's GitHub repo: https://github.com/JVKdouk/RedKite

The Outcome

We have been using RedKite internally for many projects, and the result speaks for itself. No more messy key management, no more flaky orchestration setup, or guessing the deployment/recovery path works correctly. One deployment modelling interface, and all deployment needs are covered. This has massively improved DevOps satisfaction internally.

Any feedback is very welcome. Use it and break it. The long term goal is to finally cover the deployment gap we have today on the Node.js ecosystem, and any help with that is very much appreciated!

Top comments (0)