During my onboarding one developer told me, half-jokingly, that he'd stopped shutting down his computer since joining the project. He just put it to sleep because he didn't fancy the idea of opening 15 terminals the next morning to start the app.
The product had a Next.js frontend, a NestJS backend, Keycloak for authentication, 11 background workers (Node.js), PostgreSQL, and Redis. Some parts ran in Docker, others locally. Different services used different Yarn versions.
And, as often happens with projects that started as MVPs, documentation was scarce, knowledge lived in developers' heads, and asking about the deployment process usually earned you a nervous smile.
During onboarding, I asked my onboarding buddy why the application was throwing errors on startup.
Buddy: "Oh yeah... I forgot about that. You need to change a flag in the .env file. It's known tech debt."
Nico: "Okay, do we have these things documented somewhere? At least in GitHub issues?"
Buddy: "Not really... but we should start."
That pretty much summed up the situation.
Getting the whole thing running involved 17 onboarding steps, about 15 terminals, and a collection of edge cases you were somehow expected to remember.
Honestly, one of the worst setups I've encountered.
The product had grown. The process needed to catch up.
I joined in August 2026. The product was already handling financial data for real businesses. Not exactly the kind of software where you want to discover deployment problems in production.
A broken release could affect customers and damage the owner's reputation. And with so many manual steps, every deployment depended on someone remembering the right procedure.
Understandably, every deployment was tippy-toeing on egg shells.
The existing setup came from a period when getting the MVP built was the priority. It had done that job. Now the business needed repeatable releases and a system it could eventually offer for self-hosting.
I was hired to handle three things:
- Make deployment a documented, repeatable procedure.
- Reduce build times.
- Make the whole application straightforward to run.
After an initial phase of discovery and onboarding I proposed the approach and expected outcome. The client's project manager approved it, and I owned the implementation through to delivery in mid-September.
Meanwhile, the other four developers kept building features and fixing bugs. We had to do this while the product kept moving.
First, make starting work less of a project
One thing became clear during discovery: not everyone needed the entire system running all the time.
Someone working on the frontend didn't need 11 background workers consuming resources. Someone changing worker logic needed the opposite. And occasionally, we needed to reproduce how the application would behave in production.
So I introduced four startup modes:
| Mode | What it runs |
|---|---|
hybrid-app |
Core apps with hot reload. PostgreSQL, Redis, and Keycloak in Docker. No workers. |
hybrid-all |
The same setup, including all background workers running locally. |
prod-app |
Core services running as production Docker images. No hot reload or workers. |
prod-all |
The full system running as production Docker images, including all workers. |
The startup instructions became:
- Clone the repository.
- Run one command to create the local environment files from the supplied examples.
- Choose a mode and start the application.
There was another reason to containerize the full system.
Self-hosting was on the client's roadmap. Not an immediate priority, but something they wanted to do eventually.
Since I was already restructuring how the application started and ran, it made sense to account for that requirement now. The same changes that simplified local development also gave us a foundation for running the application outside the existing cloud setup.
Some developers were reluctant initially. They knew the old process and didn't particularly feel the need to change it. Initially, I assumed the reluctance came from not having enough time for implementation, not because the proposal was inherently wrong.
Turns out, I was wrong.
What they actually worried about was trading a painful but predictable setup for something that would break their local builds after merging any of my PRs. Nobody wants to spend the first hour of their day debugging Docker instead of working on the product.
Honestly, fair concern, I cannot blame them here.
So I delivered a small pilot first. Just the core application in hybrid mode, without the workers. Something they could try in their daily work while I continued building the rest.
I kept asking for feedback on Slack and bringing it up during our dailies. Probably more often than they would've liked.
But they started using it. And to my (and probably theirs too) surprise, they liked the new setup even more than I'd anticipated.
That was a useful lesson for me that I often think about.
A release shouldn't depend on who remembers the steps
Before, engineers built the applications' images locally, uploaded them to Google Cloud manually, and ran database migrations there by hand. (Sounds scary, it is scary.)
During the work, we also found that the test and production database schemas had drifted apart. Testing against a different schema makes it harder to trust what a successful test actually tells you.
I moved database changes into versioned, forward-only PostgreSQL migration files in the repository. Environments could follow the same migration history, and we could recreate the schema from scratch. For already existing data, I had to create some targetted migrations and align the databases manually.
Then I built the release workflow around GitHub Actions and Google Cloud.
A release started with someone choosing a commit from main. Google Cloud handled the image builds because our benchmarks showed it was faster and cheaper for our need workflow.
The process then:
- Applied any pending database migrations.
- Created a new application instance receiving no customer traffic.
- Ran end-to-end tests against its own URL.
- Left the tested release ready for a separate promotion step.
If any of the deployment steps failed, the workflow stopped and kept the logs and artifacts needed to investigate. The new version wouldn't automatically receive customer traffic.
Promotion, by client's decision, stayed manual. We wanted automation to remove repetitive work and reduce human error, but the decision to expose a new release to customers remained with the team. The team could choose how much traffic to send to the release, including a small percentage before moving everyone over or accessing the release themselves to do some double-checking.
Test deployments happened roughly 2-3 times a week, and production releases 1-2. We didn't need to build and deploy every change that landed on main.
Rolling back needed a clear procedure too
Previous application versions stayed available, so a separate GitHub Action could send traffic back to an earlier version. Bug found? Make a rollback manually. Everyone happy. Straightforward.
The tricky part was the database. Our forward-only file-based migrations were, well, forward-only, by design, you don't want to reverse them. We agreed to design changes to remain compatible with the previous application version and added compatibility checks to CI to support that release process.
I also introduced stress testing through GitHub Actions, with an option to run the tests manually against a deployed environment. This was particularly useful when changing worker logic or database queries. Instead of assuming a change would handle the expected workload, we had a way to test it.
The procedures were documented, including migrations, promotion, rollback, and what to investigate when a step failed. Devs-friendly and Agents-ready.
Seventeen minutes was a lot of waiting
The full local build originally took about 17 minutes on an M1 MacBook Air with 16 GB of RAM. Not exactly a cutting-edge machine, but a good benchmark for what developers could realistically be working with.
I introduced Docker build caching, changed the build setup, and ran independent steps in parallel where possible. I also brought in Turborepo and Turbopack to reduce unnecessary work during builds.
Full build went from 17 minutes to 4 minutes. 4.25× faster. 76.5% redution in build time.
That figure describes the build workflow, not the entire deployment. Tests, migrations, and the decision to send traffic to a release were separate steps.
Four people were still changing the code underneath this
The individual technical changes weren't a rocket science. Getting them all to work together while 4 developers continued shipping features was the real challenge. Our coordination and communication needed the most care.
We used trunk-based development, merging changes into main frequently. My platform changes had to fit around the team's product work. A dependency change might require a rebuild. Another change might require resetting the local database. All of this having in mind deadlines from the Product Owner wanting to see new functionalities and development moving forward and SLA for enquiries that might arise.
Without clear instructions, a developer could pull working code and spend the morning wondering why their app failed to start.
We worked asynchronously, so I put those instructions in the PRs: what changed, what to run after merging, and what to expect. And also pinging our update chat on Slack linking the PR.
To say the least, quite subtle and delicate operation. I would be lying if I said there were no problems, of course there were bumbs on the road, it's normal with the high-velocity development but I can proudly say: 6 weeks timeline and all business requirements were met.
Then someone new joined
After the changes landed, we had to have a proof they are clear for someone new. All the documentation written would be pointless if we actually couldn't use it. It would mean that the process changed but the details still live in devs heads. No good.
And we had a perfect opportunity for it - A new developer joined.
I pointed him to the documentation and asked him to try setting up the application without my help. If anything was unclear, I wanted to find out while I could fix it. I didn't leave him to dry; had he found any problems I asked him to contact me ASAP.
He told me it took about 15 minutes from cloning the repository to seeing the application running on his machine.
He spent around 1.5h reading through the documentation and understanding the local workflow. He didn't need to ask me any setup questions.
Full, undisputable success. That's the part I'm happiest about.
Someone who had never touched the project could arrive, follow the instructions, and start working. The existing team had already switched over. Releases had a procedure people could inspect and repeat.
And starting the app was finally a reasonable thing to do in the morning.