Convention Over Configuration, Applied to CI

rubyrailstoolingbuilding-in-public

At KUY.io, we deploy to our own private cloud infrastructure. Not hosted services in AWS, GCP, or Heroku. Our own servers, our own Docker registry, our own gem server. Hosted by DigitalOcean. This is a deliberate choice: we want to control the stack, but it also means most CI tools don’t fit.

GitHub Actions assumes you’re deploying to a cloud provider. Its marketplace is full of actions for AWS, Azure, GCP. Self-hosted runners exist but they’re an afterthought. You end up fighting the platform’s assumptions at every step. GitLab CI is better for self-hosted, but it means running all of GitLab, which is its own operations burden. And Jenkins… well let’s say it comes with its own set of issues.

What I actually want sounds simple: a CI server that I can point at a Rails or NodeJS project in a git repo, have it figure out automatically how to build and test it. When the build succeeds, I want an option to deploy, regardless of whether it’s a Docker image to our registry or a binary to an application server. I want to see every commit that went into a build, and I want to see what build is currently deployed. The kind of CI nirvana that Atlassian’s Bamboo server once promised but never fully delivered on.

Zero-config discovery

We’ve been working on exactly that tool for a few weeks now. It’s called Wave-CI. It looks at your project and infers the build steps. Finds a Gemfile? Runs bundle install. Finds a Makefile? Runs make. Finds .rubocop.yml? Runs rubocop. Finds a spec/ directory? Runs rspec. Finds a Dockerfile? Builds it with Docker.

Eight discovery plugins, come out of the box: Bundler, NPM, Capistrano, RubyGems, RSpec, RuboCop, Dockerfile, Makefile. For most of our projects, this is all we need. But it’s also fully extensible should the stack ever grow. The neat thing: there usually is no configuration file at all - create a Stack (see below), and Wave CI figures out the rest.

When you do need customization, there’s a wave-ci.yml that lets you override any step, add deploy targets, define placeholders and variables, and configure your notification channels. But the defaults are right often enough that most of our projects don’t have one.

This is the Rails philosophy applied to continuous integration: convention over configuration, sensible defaults, explicit overrides when you need them.

Stacks, not pipelines

The core abstraction is a Stack: a repo, a branch, and an environment. Not a “pipeline” or a “workflow” that you wire together from arbitrary stages. A Stack has Builds (test your code), Deployments (push to production), and Rollbacks (revert). Three distinct concepts that match how we actually ship.

Everything is organized around commits. The UI shows each commit with its status: unbuilt, built, deploying, deployed. You can lock individual commits to prevent deployment. You can lock entire Stacks during a release freeze. When a Stack syncs (every two minutes), it discovers new branches and automatically creates child CI Stacks that run builds against them.

Beyond CI: Continuous Deployment

This is the part that surprised me most about the state of CI tooling: almost nobody treats deployment and rollback as first-class concepts. Jenkins has deploy plugins you bolt on. GitHub Actions has you write custom workflow steps that happen to push artifacts somewhere. CircleCI and Travis treat deployment as “another build step that runs after tests pass.” The deployment is an afterthought, something you wire together from primitives, not something the tool actually understands.

Wave CI has Builds, Deployments, and Rollbacks as three distinct task types with their own configuration, their own state machines, and their own UI. A Deployment isn’t “a build step that pushes to production.” It’s a separate concept with its own pre/post hooks, its own variables (often secrets like registry credentials), its own retry and interval settings, and its own tracking. The system knows which commits have been deployed to which environment and when. A Rollback isn’t “run the deploy again with a different SHA.” It’s its own task type with its own steps, because reverting often involves different operations than deploying (database rollback scripts, cache invalidation, etc.).

Right now deployment is opt-in: the default requires human approval. But for libraries and internal packages, we can enable auto-deploy. As builds succeed, artifacts are automatically pushed to our Nexus server and are immediately available to everyone. For production applications, a successful build shows a “Deploy” button on the commit. One click, and the deploy task runs with full output streaming, the same way builds do.

Having deployment as a first-class concept also means the UI can show deployment state meaningfully. A commit isn’t just “built” or “not built.” It’s “unbuilt”, “building”, “built”, “deploying”, or “deployed.” You can see at a glance which commit is live in production, which commits are built and waiting for approval, and which haven’t been built yet. That visibility is something you just don’t get when deployment is a custom script tacked onto a build pipeline.

Where we are now

The project started on May 31st. Three weeks later Wave CI could sync repositories, discover branches, auto-detect build steps, execute tasks with live output streaming, post build status to GitHub and Bitbucket, send Slack notifications, and deploy Docker images to our registry.

The incredible build speed was partly because the scope was narrow. We weren’t building a general-purpose CI platform, but a CI server for our specific workflow: Rails apps in Docker, deployed to our servers. That’s a much smaller problem than what Jenkins or GitHub Actions solve, and a much smaller problem is a much faster build.

Deployments and rollbacks are working end to end now. Commit locking too. The Docker build pipeline pushes images to our registry. There’s a long list of things I still want like chunked output storage for large build logs, task abort from the web UI, proper settings management, but the core loop works. Build, deploy, roll back. That’s the thing.

What it runs on

Production is Docker Compose on a single server: a web process, three worker replicas for task execution, three sync workers for repository operations, two notification workers, a scheduler, PostgreSQL, and Redis. Watchtower watches for new images and auto-deploys.

Every build pushes Docker images to our private registry. Gems come from our Nexus server. Build status posts back to Bitbucket Server. The entire pipeline is self-contained.

The thing I didn’t fully appreciate until we had it: end-to-end visibility. Wave CI tracks which commits made it into which builds, and which builds got deployed to which environment, and when. That sounds like a nice-to-have until something breaks in production at 11pm and you need to answer “what changed?” fast.

With most CI tools, you’re piecing that story together across multiple systems. The CI dashboard shows builds, the deploy tool shows releases, git log shows commits, and you’re mentally cross-referencing timestamps to figure out which commit introduced the problem. In Wave CI it’s one screen. The Stack view shows the commit timeline with deployment markers. You can see exactly which commits are live, scroll back to the last known-good deployment, and hit rollback. The commit that caused the issue, the build that packaged it, the deployment that shipped it. It’s all connected because it’s all the same system.

That traceability is also why commit locking exists. When someone says “don’t deploy this yet, it depends on a migration that hasn’t run,” you lock the commit. It stays built but can’t be deployed until you unlock it. Simple, but it prevents the class of incidents where a commit gets swept into a deployment before it’s ready.