Fullscript Logo
Technology,  Engineering,  DevOps & Infrastructure

Local-First Dev Environments at (Medium) Scale

Author

Ryan Brushett

Date Published

Share this post

How Fullscript replaced K8s-heavy local- and cloud-based dev environments with local Docker Compose and never looked back.

TL;DR: We had two developer infrastructure options at Fullscript. A Kubernetes-heavy local setup and a cloud-based setup running in Amazon Elastic Kubernetes Service (EKS). Both were overly complex and neither scaled well. We replaced both with a more scalable local solution backed by Docker Compose, rolled it out over three (ish) months, and killed the old systems entirely. Support requests dropped, developers are happy, and we're not looking back!


Every few years, a new developer tooling paradigm captures the interest of engineering teams. Nix promises perfectly reproducible environments; cloud-based infra lets you code from an iPad or a Chromebook; and devcontainers offer one-click ephemeral environments for any project. The demos are always impressive.

Don't get me wrong, these projects are all great, but at Fullscript we found that what our developers want is actually much simpler than any of that. They're builders! They want to clone a repo, run a command, and start building features. Simplicity and reliability are valued over cleverness. Minimal friction!

We (re)learned this lesson the hard way; we had a cloud development environment and a local development environment. Somehow, neither was great. So we did what engineers do: we built something new. We stripped it back to the basics and kept things really simple.

Where We Started

Fullscript is a healthtech company with a substantial Rails monolith, plus a growing constellation of supporting services. Like many scaling engineering organisations, we'd tried multiple approaches to developer environments over the years.

Old Local Dev: Too Prod-Like

Our first local development setup tried to mirror production closely. The thinking was sensible: if it works locally, it'll work in prod. So we ran Kubernetes locally: very similar service configurations, production-like infrastructure on every laptop.

Parity with production in theory meant fewer "but works on my machine" surprises, which seemed like a good trade-off. But production-like infrastructure on a laptop means heavy resource usage and complex debugging when things go wrong.

Worse, as production infrastructure grows more complex (and it always does), attempting to maintain that parity means the dev environment has to grow in lockstep, often for no good reason. Production environments exist to stay alive, stay secure, and scale under load. Dev environments exist to enable experimentation, to be nimble, breakable, and easily recoverable. Coupling the two meant we were dragging production's complexity into a context where it actively worked against us.

It also exposed the eternal truth of developer tooling: no two developers' machines are really alike. Someone's on an older version of Rancher. Someone else installed libxml in 2019 and it hasn't been updated since. Someone's .zshrc is doing something cursed (honestly, guilty).

New developer onboarding was inconsistent. Sometimes it took an hour of handholding where we wanted it to take single-digit self-serve minutes. The support burden wasn't shrinking. It was growing quickly. This approach wasn't scaling with the company.

Cloud Dev: The Reality

So, we tried something different. Our cloud development setup gave each developer their own Kubernetes namespace running in EKS. On paper, it was elegant: consistent environments, no local resource drain, didn't matter how cursed the workstation was, and it gave us the ability to throw away and rebuild infrastructure quickly and easily.

In practice, it was a lot of complexity; K8s, Helm, Helmfile, EKS, code sync between local and remote. When it worked, it was OK. But debugging issues meant understanding layers of abstraction that had nothing to do with the code you were trying to write. AWS auth sessions expire, for example, and this interrupts flow state.

The bigger problem was that we couldn't see a path forward. We could have fixed all of these problems for The Monolith. We really could have sunk a bunch of time and tooling into making Cloud Dev feel great for that one codebase. But Fullscript has a growing constellation of supporting services, and each one would need its own Helm charts, its own configuration, its own debugging surface area. We can't maintain a full fleet of EKS clusters to support cloud-based dev environments for every project. We wanted to enable experimentation, not gate it behind infrastructure overhead. Cloud Dev was never going to work for that.

It was also expensive, and AWS account limits are real. There are hard ceilings on things like pods per node, IP addresses per VPC, and services per cluster. Scaling developer infrastructure in the cloud hits limits fast.

The Philosophy: Enabling Growth, Not Reacting to It

We could have invested time into making Cloud Dev work better. But that would have meant maintaining two competing standards for developer environments, and two standards is really no standard at all. Our team would be split-brained, building and supporting two ways of doing the same thing. That's not a good use of anyone's time.

Instead, we asked ourselves a different question: what would developer tooling look like if we designed it to enable growth rather than reacting to pain? At Fullscript, we put a premium on speed. Not recklessness, but smart, fast decisions that remove friction. We wanted something that grew elegantly with the business, enabled experimentation, and got out of the way. The answer came down to a few principles:

Dev tooling should be exactly as complex as it needs to be, and no more. Development infrastructure exists to enable experimentation and growth. Get messy, make mistakes, break things and fix them! Anything more complex than what's needed to support that gets in the way rather than enables. Docker Compose has been around since 2014. Its strength isn't just that everyone already knows it; it's that it's relatively simple, well-documented, and easy to debug when things go wrong.

Teams should own their environments. A platform team bottleneck hurts experimentation. If every new application, tool, demo, etc. needs us to help configure something, we become a drag on velocity instead of enabling teams to go faster! So we flipped the relationship: the repo tells our tooling what it needs, not the other way around. Each project gets a small declaration file (rx_dev.yaml) that describes its dependencies, infrastructure, and setup steps. Teams add what they need, when they need it, without filing a ticket. That file also serves as necessarily-true documentation for what the app requires in development. Teams make their own decisions within sensible guardrails, and our tool puts things in place. We'll show an example of what this file looks like later.

Local-first means fewer things to stumble over. Cloud Dev's biggest day-to-day pain wasn't any single thing being slow. It was the accumulation of small friction points: a sync process that silently died, a test run that was extra slow because you forgot to shell into the remote pod first, an auth token that expired mid-flow. None of these were dealbreakers on their own, but together they were death by a thousand paper cuts. Running locally removes entire categories of things that can go wrong. Your code is right there with you. At Fullscript, we want our folks to work where they work best. Whether that's a home office, a coffee shop, a hotel with questionable wifi, or on a plane. It all just works.

Everyone deserves good dev tooling. Whether it's our Rails monolith, a small Go web service, or a collection of Python scripts, we wanted the same approach to work everywhere and all of our developers to feel like first-class citizens. Clone the repo, run rx dev up, and you're ready to go. New employee joining the company? rx dev up. Switching teams? rx dev up. It doesn't matter what the codebase is or what team owns it. The experience is consistent.

What We Built

Our solution has a few components, all deliberately simple.

The CLI: One Command to Start Everything

We already had a Go command line tool called rx (get it? Healthtech? rx? heh). It's a bit of a swiss army knife of developer tooling at Fullscript. An rx dev up command had existed for years, but it was tightly coupled to The Monolith's development Kubernetes setup and couldn't start dev infrastructure for other projects.

What we built was a ground-up rethink. Same command, completely different internals. Now it:

  • Installs required language runtimes and tools
  • Starts local infrastructure (databases, caches, search engines)
  • Configures environment variables
  • Gets you ready to contribute

The command is idempotent. Run it as often as you like! After pulling changes, after switching branches, whenever something feels off. It checks what's already done and only does what's needed.

The Config: Per-Project, In-Repo

Each project gets an rx_dev.yaml file in its root. Here's what one looks like:

This tells rx everything it needs to know: install Ruby (version from .ruby-version if available, otherwise latest), install some Homebrew packages, set environment variables (pulling secrets from our encrypted store), spin up MySQL and Redis containers, and register a lint task.

If this pattern looks familiar, it should! Shopify's dev tool pioneered this approach with their dev.yml files and dev up command. We're big fans of their work! Our implementation is tailored to Fullscript's specific needs and stack, and based on some things we've learned over the years, but the core philosophy of per-repo config files that declare what a project needs is very much inspired by what Shopify built.

The key insight is that teams own their configuration. Want to add Postgres? Add it to the yaml. Need a new environment variable? Add it to the yaml. No tickets to the platform team required.

Under the Hood: Docker Compose

We evaluated other tools. We looked at the Docker Compose Go SDK for programmatic container management. We considered various orchestration approaches. In the end, we decided to just shell out to the Docker Compose CLI.

Why? We wanted a strong foundation to build from. Docker Compose has been around since 2014. The failure modes are well-understood. When something goes wrong, developers can run docker compose commands directly. They can read the generated compose files. There's no abstraction hiding what's actually happening. Every engineer knows how to debug it.

We also use Shadowenv (another Shopify project) to manage infrastructure ports per-project. Each project gets its own set of RX_PORT_* environment variables that are automatically set when you cd into the project directory. This means multiple projects can run side-by-side without port conflicts, and it also enables git worktree support out of the box.

For the container runtime, we standardized on Rancher Desktop. It's open source, handles the Linux VM layer on macOS transparently, and works well on Apple Silicon.

We think this foundation will grow nicely with us. We're not locked into anything exotic that might become a dead end, and the ecosystem keeps improving! Apple's native container support is on the horizon, for example. We're excited about that as it should help us get rid of the Linux VM layer entirely!

The Rollout

We rolled the new system out gradually to minimise chaos, but when it came time to cut over, we really cut over. We were determined not to end up with now three competing standards, and that's worse than any amount of short-term disruption or support overhead.

Beta testers. We started with a closed beta of volunteers who were genuinely excited about the new approach. This is one of the things we love about Fullscript's culture: people don't just tolerate change, they lean into it. These folks were willing to help us find rough edges, provide feedback, and learn along with us. Over the course of the beta, we found a lot of ways to improve. Remember "no two developers' machines are alike"? It bit us repeatedly here. We had to add minimum version detection for Homebrew packages, detect when Rancher Desktop was installed outside of Homebrew, handle git worktrees, different shell configurations, and edge cases we'd never considered.

Documentation as a first-class feature. We treated docs as part of the product, not an afterthought. Migration guides, troubleshooting pages, getting started guides. We also made an early habit of turning support interactions into documentation. If a beta tester asked about something, chances are they weren't the only person thinking it. This practice stuck and continued well after the beta. If developers couldn't figure something out from the docs, that was a bug in the docs.

Opening it up. After closed beta, we opened it up more broadly. Still opt-in, but actively encouraged. We gave a dev talk, posted in Slack, and actively supported people through the transition. The feedback was overwhelmingly positive. People weren't just tolerating the new system; they actively preferred it.

The cutover. Once we were confident in the new approach and had given people notice, we did the cutover overnight. One Monday morning, developers came in and there was simply no way to use Cloud Dev anymore. Old Local was gone too. All of the legacy behaviours were removed from rx (we deleted so much code 🤩), and it could only support the new local approach. We were able to do this cleanly because of how rx handles updates: we don't rely on developers upgrading the tool themselves. We can push out updates and know that everyone will have the new version within minutes. When we finally sunset Cloud Dev completely, it felt like a celebration rather than a loss.

What We Didn't Build

Nix. Reproducible builds, declarative package management, the whole thing is genuinely elegant and we love it. But we needed developers to be able to self-support and support each other, and that's a lot harder when the underlying tooling is unfamiliar. Docker Compose is intuitive enough that most engineers can reason about what's happening and debug problems on their own. Nix would have required layers of abstraction to achieve that same accessibility. We wanted to ship in weeks, not months.

Production parity. Dev environments and prod environments have very different needs. Our old local dev tried to mirror prod. The new one doesn't. We run MySQL, Redis, Opensearch, and a few other things in containers. We're not simulating the full production topology locally. Turns out you don't need to for development work!

The Docker Compose Go SDK. It's the official way to manage containers programmatically in Go, so you'd think we'd use it. We evaluated it, but it introduced CGO dependencies that would have required macOS build machines, and we build on Linux. We would have loved to use the official libraries (it removes a dependency on the user's shell), but shelling out to the Docker Compose CLI turned out to be a nice alternative. When something fails, we know exactly what command was running when it happened and can run it in isolation to debug. Developers can do the same.

What Worked and What We Learned

The project took about three to four months from kickoff to GA (October 2025 to January 2026).

What worked

UX first, not production parity. Once we stopped trying to mirror production and started thinking about what developers actually need day-to-day, the solution got simpler fast. We didn't need the full mesh of services. It was more important that it be simple, reliable, and predictable. Thinking about it from the developer's perspective rather than chasing cleverness or architectural purity kept us focused on what mattered.

Starting with The Monolith. It's the most challenging codebase we have: the most developers, the most complexity, the most infrastructure dependencies, and the most support burden. We deliberately tackled the hardest problem first. If the new approach could work there, it could work anywhere. And it did! Every project we onboarded after that was straightforward by comparison. Often seamless.

Killing the old thing. The moment we released this project as the new default, we axed the other approaches entirely. Cloud Dev and Old Local became immediately inaccessible. No long tail of cutover, no holdouts. The support burden dropped and the confusion disappeared. Having one standard instead of three is worth the short-term disruption.

The rollout process. Closed beta with willing volunteers, open beta with active encouragement, then a clean cutover. Each phase gave us confidence for the next. By the time we flipped the switch, we knew it was ready because real developers had been using it on real machines for weeks.

Debuggability. We designed around how developers actually debug things. Good status, logs, and reset commands. Generated compose files they can read. Commands they can run directly. When something goes wrong, developers need to understand what's happening and have a clear path to recovery.

What we learned

No two machines are alike. We've said it twice already, and we'll say it again: this is the stubborn, eternal truth of developer tooling. You will not anticipate every edge case from your own machine. Beta test early, on real machines, with real developers. You'll be glad you had that feedback before you're committed.

Build native images for your architecture. Running AMD64 container images under emulation on Apple Silicon was brutally slow. We'd initially reused our Cloud Dev images, which were built for Linux AMD64 instances, just to get going. We knew there'd be a performance difference when we built ARM64-native images, but we were genuinely surprised by how dramatic it was. If your team is on Apple Silicon, just make sure you build for ARM64!

You cannot over-communicate a major tooling change. Announcements, dev talks, manager buy-in, conversations with directors, Slack messages... We did all of it. People were still surprised. Word-of-mouth and persistent nudging are just as important as the official channels. Some folks don't watch dev talks, some miss Slack posts. Plan for that. When you think you've surely communicated enough, someone will still be surprised! Over-communicate, but be ready to support those folks when it comes up.

So Far So Good!

The feedback has been really, really positive. We've noticed a steep decline in support requests in our channel, and developers genuinely seem pleased. "AWS auth doesn't time out twice a day anymore." "Setup was super smooth." "I can actually keep my server running overnight now."

We wanted something simple that worked for The Monolith, for small apps, for dev tools, for whatever our teams want to build next. No gatekeeping, no tickets, no waiting on us. We think we've got that.

The best part? New projects can onboard themselves to our dev environments now! Teams own their environments. Docker Compose isn't exciting, and that's exactly the point. We're not betting our developer experience on anything exotic. Just boring, well-understood technology that gets out of the way.

And it's just one standard. New apps don't consider how to build their dev environments, they just declare what they need. We made it easy to do the right thing.

One of our guiding principles at Fullscript is to grow faster than the company. This tooling embodies that: as the organisation scales, as new teams spin up, as new services get built, the developer environment scales with it! All without us being in the critical path. It's also freed up our team to think about other problems, like ensuring all of Fullscript's workstations are set up pretty consistently, dev data, CI performance, cataloguing services, etc.! But those are all stories for other blog posts.

This project wouldn't have happened without Jakob Valen and Lisa Ugray. They were incredible teammates throughout this project, and their impact on this project can't be understated!


Interested in working on developer experience at scale? Check out our engineering careers at Fullscript.





Share this post