Uddom Jul 2025 – Feb 2026

Splitting out nginx and moving CI to a registry: 12 minutes to 5–6

The deploy pipeline for Uddom and Voice2Policy had become the bottleneck. Pulling nginx out into its own service and rebuilding CI around a container registry halved build time and made every release an image you can roll back to.

CI run
12 min5–6 min
Docker images
~30% smaller
Issue resolution
~30% faster

The problem

Uddom (crowdfunding) and Voice2Policy (AI civic engagement) are two production platforms built on NestJS and Next.js. Both ran on an older pipeline that treated each platform as one dockerized application, with nginx packaged in alongside the app. It worked at the start, but as both platforms grew it became the bottleneck: every change, whether a line of API code or a proxy setting, went through the same slow build and deploy, and a CI run took about 12 minutes.

Two details made it worse. nginx was built into the same image as the applications, and the images were built on the EC2 server itself rather than in CI. The production host was compiling code and serving users at the same time, and the cost showed up where it hurts most: deploys meant downtime.

Decision 1: nginx as its own service

I split nginx out into a standalone service that sits in front of the applications. The reason is that the proxy and the apps change for different reasons and at different speeds. Routing, TLS and headers change rarely; the API and frontend change every day. While they lived in one image, each one paid the other's build cost, and a proxy tweak meant rebuilding and redeploying application code.

With nginx on its own, each application became a plain container that only knows how to serve its own traffic. nginx routes to them, so an app can be rebuilt and replaced behind it without touching the proxy, and a proxy change no longer rebuilds an app.

Decision 2: a registry-based pipeline

I rewrote the CI pipeline in GitHub Actions around a container registry. CI builds each application image once and pushes it to AWS ECR. The EC2 hosts never build anything: they pull the finished image, with Portainer managing the containers and Watchtower rolling out new images as they land in the registry.

  • Servers do what they're for. Building no longer competes with live traffic for CPU and memory on the production host.
  • The image that passed CI is exactly the image that runs. Nothing is rebuilt between the pipeline and production.
  • Rolling back is redeploying the previous image from ECR, not rebuilding old code under pressure.
Before: git push, then the EC2 host pulls code and builds one image with the apps and nginx inside, causing downtime. After: git push, GitHub Actions builds with multi-stage Dockerfiles and layer caching, pushes to AWS ECR, and the EC2 host pulls the image with Watchtower and Portainer behind a standalone nginx that routes to the NestJS and Next.js containers.BEFOREgit pushEC2 production hostgit pull, then docker build on the serverone image: apps + nginx togetherbuilds compete with live trafficEvery deploy = downtimeAFTERgit pushGitHub Actionsbuild oncemulti-stage Dockerfilelayer cacheAWS ECRimage registryEC2 host: pull onlyWatchtower pulls new imagesPortainer manages containersnginxstandalone serviceAppsNestJS, Next.js
Before: every deploy rebuilt one image, nginx included, on the production host. After: CI builds once, ECR stores the image, and the host only pulls it behind a standalone nginx.

Smaller images

The Dockerfiles moved to multi-stage builds: dependencies are installed and the app is compiled in a build stage, and only the compiled output and production dependencies are copied into the final image. Images came out about 30% smaller, which matters twice in a registry-based setup, because every image is pushed once by CI and pulled again by each host.

Where the six minutes came from

Most of the saving came from two things working together: Docker layer caching and the smaller images. With the Dockerfiles ordered so that dependencies are installed before the source is copied in, a typical commit only invalidates the last layers, and CI reuses everything underneath from cache instead of reinstalling it. The smaller images then take less time to push to ECR and less time for each host to pull, so the time saved in the build isn't spent again shipping the result.

One CI run, in minutes
  • BeforeBefore: 12 min12 min
  • AfterAfter: 5–6 min5–6 min

After is a range of 5–6 minutes; the bar shows the midpoint.

Watching production

Alongside the pipeline I set up the AWS side properly (VPC, RDS for the databases) and added CloudWatch monitoring with alerts. The alert that earned its place watched memory on the hosts: it caught RAM exhaustion early, while it was still a warning and not an outage. Combined with releases that map to one specific image, issues were resolved about 30% faster.

Image size and time to resolve issues, before = 100
  • Image size, beforeImage size, before: 100100
  • Image size, afterImage size, after: ~70~70
  • Time to resolve, beforeTime to resolve, before: 100100
  • Time to resolve, afterTime to resolve, after: ~70~70

Each before is set to 100 so both changes share one scale. Both drops are approximate (about 30%).

What I'd do next

Watchtower is the right tool for a small fleet: it's simple and needs no extra infrastructure. The trade-off is that a rollout is pull-based and implicit. At a larger scale I'd move to an explicit, push-based deploy step with health checks before traffic shifts, so a bad image is stopped by the pipeline, not noticed after it's live.