DEV Community

Cover image for What Splitting a Docker Image Actually Costs You (SPA Routing, CORS, and Five More Surprises)
Jeff Ronnie
Jeff Ronnie

Posted on AI-assisted

What Splitting a Docker Image Actually Costs You (SPA Routing, CORS, and Five More Surprises)

A few weeks into DevOps work on our team's chama (savings-group) app, I made a call that seemed simple on paper: ship the Go backend and the React frontend as one combined Docker image, let the backend serve both the API and the static files. It worked. Then, later, the team moved to two separate images instead, one for the backend, one for the frontend behind nginx. That second move is what this article is actually about, because it broke more things, in more interesting ways, than the original build ever did.

Why one image first

The initial reasoning wasn't laziness, it was a real tradeoff. Splitting into two images buys you independent scaling, independent release cycles, and a setup that matches how you'd actually run this in production behind a CDN and a load balancer. But none of that mattered yet: no real traffic, no divergent release cadence between frontend and backend, and the team was still learning Git branching basics. Paying for architecture you don't need yet is still a cost, so we kept it simple, one multi-stage Dockerfile, Node stage building the Vite frontend, Go stage compiling the backend, a lean Alpine final stage shipping only the compiled outputs of both.

Why two images later

The calculus changed. The backend needed to live on a platform built for long-running servers, while the frontend, static files with zero server logic, could eventually sit on a CDN-backed host instead. Keeping them welded together meant the backend's deploy pipeline dictated the frontend's too, and vice versa. So we split. Two Dockerfiles, backend/Dockerfile (Go only) and frontend/Dockerfile (Node build stage, nginx final stage), two services in docker-compose.yml, and a small pile of consequences I hadn't fully priced in going in.

The first consequence: nginx has to solve the same problem Gin already solved

When the backend served the frontend directly, a single-page app's routing problem showed up once, and got fixed once. Single-page apps load one HTML shell, then handle navigation in JavaScript, no real page reload. The problem: if someone reloads the browser on a client-side route, say /dashboard, the browser sends a genuine HTTP request for /dashboard to the server. If the server doesn't know what that path means, it 404s. We'd fixed this with a Gin NoRoute handler falling back to index.html, letting React Router take over once the shell loaded.

Split the images, and that fix disappears with the backend-serving-frontend code it lived in. Nginx, now responsible for the frontend entirely, has no idea this problem exists unless told. The fix is the same idea in a different tool:

nginx
location / {
try_files $uri $uri/ /index.html;
}

try_files checks for a real file matching the request, and falls back to index.html if nothing matches. Functionally identical to the Gin fix, different config language, same underlying problem. This was the first real lesson: splitting an app into separate deployable units doesn't eliminate cross-cutting concerns, it just means each unit now has to independently solve the ones that touch it.

The second consequence: CORS, which simply didn't exist before

When one process serves both the API and the frontend, every request is same-origin, the browser never questions it. Split into two services on two different ports, and suddenly the browser treats them as different origins, by default, cross-origin requests get blocked outright.

This meant adding CORS middleware to the Go backend that had never needed it:

go
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:5173"},
AllowMethods: []string{"GET", "POST", "PATCH", "DELETE"},
AllowHeaders: []string{"Authorization", "Content-Type"},
AllowCredentials: true,
}))

The detail that actually mattered here wasn't the code, it was remembering that AllowOrigins: []string{"*"} is a fine shortcut for local development and a real liability the moment actual users are involved. The origin needs to be the real, specific deployed frontend URL in production, not a wildcard, something easy to forget once the wildcard works and nobody circles back to tighten it.

The third consequence: the frontend needed to know where the backend lives

Same-origin relative paths like fetch('/api/v1/chamas') work fine when frontend and backend share a process. Split them, and that relative path resolves against the frontend's own origin, not the backend's, it simply breaks. The fix was centralizing the backend's URL behind an environment variable:

js
// frontend/src/config.js
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

Small change, but it's the kind of thing that's invisible until you split, and then immediately obvious once every API call silently stops working.

The fourth consequence: deciding which Docker image actually goes where

Not every image generated locally needs to ship the same way in production. The frontend's frontend/Dockerfile, Node build stage plus nginx, exists mainly for local parity, running something close to production conditions on a laptop. But in actual production, a static site host (Render's Static Site type, or Vercel, Netlify, Cloudflare Pages) does the same job with less to manage: no container to keep healthy, no memory allocated just to serve files that could sit on a CDN instead. The backend, by contrast, genuinely needs a platform built for long-running processes, a static host can't run it at all. Splitting images doesn't mean treating both halves identically once you reach deployment, it means recognizing that each half might want a different kind of home.

The fifth consequence, and the one that actually cost the most time: configuration drift

This is the one worth dwelling on, because it wasn't a single bug, it was a category of bug that kept recurring in slightly different disguises. The database's username, password, database name, and even host port all live in more than one place now: docker-compose.yml's db service, the backend service's environment override, and a local .env file used when running the backend directly outside Docker. Three places, one fact each needs to agree on.

They drifted. Repeatedly. A teammate changed the database's password in one place without updating the backend's override elsewhere in the same file. A local Postgres install already using port 5432 forced moving the host port to 5433, which then collided with an entirely unrelated project's own Postgres container also sitting on 5433. Each time, the actual error (password authentication failed, connection refused, port already allocated) pointed at Postgres or Docker, but the real cause was always the same: config that was supposed to describe one fact, written down in two places that had quietly stopped agreeing.

The fix going forward isn't cleverness, it's reducing the number of places a fact can be written:

yaml
services:
backend:
environment:
DATABASE_URL: "postgres://chama:${DB_PASSWORD}@db:5432/chamadb?sslmode=disable"
db:
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}

One variable, defined once, referenced everywhere it's needed. Change it in one place, every consumer picks it up. This wouldn't have prevented every issue in this project, but it would have prevented most of them.

The sixth consequence: CI and CD had to learn there were now two things to watch

Before the split, one pipeline built one artifact. A single build-and-test job compiled the Go module and ran its tests, a single cd.yml built the one Dockerfile and published one image to GHCR. The mental model was simple: one thing can break, watch that one thing.

Two images means two independent things that can each break on their own schedule, and CI needs to say which one, clearly, without making anyone dig through a wall of combined log output to figure out whether it was the frontend's npm run build or the backend's go build that failed. We ended up with the backend's build/test as the one truly blocking job, since a broken compile is unambiguous breakage, while the frontend's build and lint run as an advisory job that reports clearly but doesn't halt anyone's merge, a broken npm run build matters, since it would also break the Docker image, but it was judged not yet worth blocking a team still getting comfortable with the review workflow itself.

The CD side needed a real decision too, not just more YAML: publish both images to GHCR from the same workflow, or split into two deploy workflows entirely, one per service. We went with one cd.yml building both, mostly because the two images are versioned and released together in practice, there wasn't yet a real scenario where you'd want to ship a new backend without a matching frontend build also existing, even if they don't have to deploy at the exact same moment on the hosting side.

The seventh consequence: deciding what "done" even means for a two-image smoke test

With one image, a smoke test was straightforward, hit a handful of URLs on one running container, check status codes, done. With two images, the smoke test needs to verify something it never had to check before: that the two services can actually talk to each other at all. A backend that returns a clean 200 on /health and a frontend that renders a blank page because it can't reach the backend both "pass" if you only test each service in isolation. The real test has to go one level up, load the frontend in a way that exercises an actual API call, and confirm the response makes it back, not just that each container independently boots.

This sounds obvious written down, but it wasn't obvious while building it, the instinct when something's graded or reviewed is to verify each piece works, and stop there. Splitting an image doesn't just split the thing you're building, it splits your definition of "working" into "each piece works" and "the pieces still work together," and only the second one is the one that actually matters to a user.

What splitting an image actually teaches you

None of these five consequences were visible from the single-image setup, they only existed because two things used to be one. That's the actual lesson underneath all of this: an architectural decision like "one image or two" isn't really about Docker syntax, it's about how many implicit assumptions your current setup is quietly resting on. Same-origin requests, a single source of config truth, one process owning both routing concerns, all of that was free when everything lived in one image, and every one of those became a decision again, requiring its own explicit fix, the moment the boundary moved. Worth knowing before making that move, not after.

Top comments (0)