Test Runners in Multi-Stage Docker Builds

While working with Docker builds, you may already be familiar with building images and creating containers:

$ docker build --build-arg FOO=BAR FOUX=BARS -t my-docker-image:latest .
$ docker run -d -p 8088:8080 my-docker-image

This would build and create a container for the following Dockerfile:

FROM node:20-slim AS builder
WORKDIR /app
COPY . .
RUN yarn install --immutable --immutable-cache --check-cache
RUN yarn build:prod

FROM builder AS test
RUN yarn test:integration

FROM node:20-slim AS final
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/yarn.lock ./yarn.lock
RUN yarn install --production --frozen-lockfile
ENTRYPOINT ["node", "./dist/src/server.js"]

Need to run just the test layer in CI?

Enter the --target flag:

$ docker build --target=test -t my-api:test .

The --target flag will build the Dockerfile from the top to the specified stage, and then stop — skipping any further layers like the final production image. This is ideal when you only want to run tests in a CI/CD pipeline without building the full image.

CI/CD Example

- name: Build Docker image for testing
  run: docker build --target=test -t my-api:test .

- name: Run integration tests in container
  run: docker run --rm my-api:test

Benefits of this approach

  1. You maintain clean image separation and avoid adding test-only dependencies in the production image.
  2. Caching becomes smoother since your pipeline will cache the build layers and skip test runs in the production builds.
  3. It becomes clearer during debugging if the integration (or whatever test suite you want to run), since the test logs are isolated from the app runtime.

“… When You Get A Chance?”

Before I went on summer vacation from work, I was asking a coworker for help with an urgent PR. I needed to merge the frontend component in order to get the whole feature out. (It was a full-stack project.)

I shot off “when you get a chance” at the end of the message.

And I waited. And waited. And waited.

When I checked back to see if they had read the message or not, that little qualification, the throw-away prefix, glared at me.

And it hit me. Well… I said to him, when you get a chance. So, right now he can’t look at it.

So, I sent off two messages in a single message – this is urgent and WHEN YOU GET A CHANCE NO PRESSURE.

I went back and edited the message, apologizing for my lack of clarity and double messaging. Then, I made it clear that I would appreciate his help with him as soon as he can.

I got the approval I needed to move forward.

A really fascinating moment in how we communicate and how throwaway phrases can betray our intentions.

The CI Failure That Could Have Cost Us 42,000 SEK/Year — Until a 2-Line Fix

This week, I merged a PR into develop that looked clean locally — only to have it fail in CI after merge.

The reason?
My dev build passed, but there was no local process or a step in our feature branch pipeline that attempted a production build before committing and/or merging.
A small TypeScript mismatch slipped through — and CI caught it only after it was merged.

Root cause

A type import didn’t get checked in the local dev build.

tsc –build in production mode caught it — but that step wasn’t part of local workflows.

Our develop branch builds from scratch using yarn build:prod.

Fix

I added a yarn build:prod step to our Husky pre-commit hook.
Now, prod build errors fail before the commit even hits GitHub.

Impact — Quantified

1. Engineer Time Lost to Debugging

Avg 1 engineer loses ~30 min diagnosing unexpected CI failure
Happens ~2x/month in teams of 3–5
= 1 hour/month x 1,500 SEK = 1,500 SEK/month

Annualized: 18,000 SEK/year just in lost debugging time

Reduced CI re-runs

  • 5 avoidable failed builds/month + 15 min of rework
    → 2,000 SEK/month = 24,000 SEK/year

Compute cost reduction

  • ~75 CI minutes/month avoided
    → 4.5 SEK/month = ~54 SEK/year

Total estimated savings: ~42,054 SEK/year from a 2-line config change.

____

So, The earlier the feedback, the cheaper the fix. If you’ve been relying on dev builds alone before merging, double check what your CI’s really doing. You might catch more with less.

Logging Setup for Frontend + Backend Observability in a Nomad-Hosted App

Context

Was neck-deep in CORS errors logging this week.

This came out of necessity — browser errors were vague, Nomad made it hard to inspect logs, and I needed better observability yesterday. So I wired it all up using winston, express-winston, and a few custom formatters. Added rate limiting and context enrichment. Removed all rogue console.log()s. Now logs feel like a dashboard I can trust.

Still not across the finish line with CORS, but now I know exactly where the requests fail. Which is more than I could say a day ago.

Problem

CORS errors in browser environments are notoriously opaque. Hosted behind Nomad, we struggled to get visibility into:

  • Whether the request reached the server
  • Why the request wasn’t showing up in Nomad logs

Goal

  • Figure out why we were getting the CORS problem in the first place

Logging Setup Goals

  • Trace CORS logic explicitly with structured logs
  • Enable frontend-to-backend error reporting
  • Separate loggers by concern
  • Format logs for human readability and machine parsing
  • Build with winston, express-winston, and standard middleware
  • Route everything through a centralized logger for observability in Nomad’s /alloc/logs output

Setup

1. Logger Container with Scoped Loggers

const container = new winston.Container();

container.add('cors', { /* ... */ });
container.add('frontend', { /* ... */ });
container.add('app', { /* ... */ });
container.add('http', { /* ... */ });

export const cors = container.get('cors');
export const frontend = container.get('frontend');
export const app = container.get('app');
export const http = container.get('http');

Each logger uses a scoped prefix ([CORS], [FRONTEND], etc.), with consistent formatting, timestamps, and custom verbosity levels.

2. Frontend Error Reporting Endpoint

POST /log Receives payloads like:

{

  level: 'error',

  message: 'Error when logging in',

  stack: 'AxiosError: ...',

  context: { data: ‘some data’, context: 'LoginPage' },

  userAgent: navigator.userAgent

}

This hits a backend controller that parses the error and logs it via the [FRONTEND] logger.

No auth required, but it’s behind rate limiting and scoped CORS.

3. CORS Logging + Debugging

A dedicated CORS logger tracks preflight behavior, incoming origins, and request flow.

app.use((req, res, next) => {

  cors.info(`Trust Proxy? ${app.get('trust proxy')}`);

  cors.info(`IP: ${req.ip}`);

  cors.info(`Origin: ${req.headers.origin}`);

  next();

});

After .use(corsMiddleware), we also log:

app.use((req, res, next) => {

  cors.debug(`Passed CORS for ${req.method} ${req.originalUrl}`);

  next();

});

4. Morgan + express-winston for HTTP Logs

[RICH-HTTP] logs track all route hits, timings, and status codes, feeding into morganMiddleware and expressWinston.logger.

const morganMiddleware = morgan(

  ':method :url :status :res[content-length] - :response-time ms',

  { stream: { write: msg => http.http(msg.trim()) } },

);

5. Middleware Order

Critically tuned!

app.set('trust proxy', true);

app.use(express.json());

// Log CORS setup

app.use(corsLoggerMiddleware);     // Trust, IP, Origin

app.options(/^\/.*$/, corsMiddleware);

app.use(corsMiddleware);

app.use(corsPassThroughLogger);    // Logs which paths passed CORS

// Frontend error logger endpoint

app.post('/log', frontendLoggerController);

// HTTP & diagnostics routes

app.use(appLogger);

app.use(v1Router);

app.use(v2Router);

Sample Output

2025-06-26 21:53:18 [CORS] DEBUG: Passed CORS for POST /log

2025-06-26 21:53:18 [FRONTEND] DEBUG: Error when logging in

  Stack: AxiosError: Request failed with status code 404

    at settle (...)

  UserAgent: Mozilla/5.0 (...)

  Context: {

    "data": "some data",

    "context": "LoginPage"

}

2025-06-26 21:53:18 [RICH-HTTP] INFO: POST /log 204 3ms

Learnings & Lessons

  • Logger ordering matters. CORS logger must run before corsMiddleware to capture the Origin. Frontend error reporting must be fast + resilient — don’t let it throw.
  • No rogue console.log() — breaks structured logs.

Outcome

With this logging setup in place and after a lot more investigation, we figured out that the load balancer was rejecting client requests because a header wasn’t in the load balancer’s allowed headers list.

A quick refactor of the client request in the FE fixed the CORS problem and we finally saw client requests in Nomad logs. Yeehaw!

The logging infrastructure is finally exactly where I want it:

✅ Server-side logs with clearly scoped loggers ([CORS], [APP], [FRONTEND], [RICH-HTTP])

✅ Timestamps, request info, and stack traces — all structured, readable, and consistent

✅ A dedicated endpoint for piping frontend issues to the backend, with user context + user agent

Future Work

  • Ship logs to centralized log aggregation (e.g. AWS)
  • Add correlation IDs to trace logs across services
  • Improve CORS failure clarity (esp. for 403 OPTIONS responses)
  • Anonymizing/sanitizing sensitive fields from frontend logs

Knowing When to Stop Refactoring

This week, I needed to confirm that a microservice could reach its database in test.
That worked — but getting there turned into a deeper refactor of the DB layer. It exposed flaws in how we handled connections. Fixing them made the system better and clearer.
Then came the familiar pull:
If I’m already in the internals…
Logging could be cleaner. Should I tidy it now?
Error handling needs work. Do I rewrite it?
The Docker image size is bloated. Should I reduce it?

Each would be a win — but also scope creep.
I knocked out two cleanly, but the third sent me down a rabbit hole before I made the call to stop.

What helped me decide?
The core issue was fixed and tested.
The changes were stable in review.
Everything else could stand on its own.

We talk a lot about knowing when to refactor.
We talk less about knowing when to stop.

Exploring UX Tradeoffs

A bug fix this week opened up bigger conversations about UX tradeoffs, first principles, and system design.

What started as a fix turned into 3 key milestones — each one clarifying what matters most in the product.

The bug was around state being lost in our front-end app. The fix was straightforward – re-fetch the data from the API.

1. Data may not be available anymore.
The API is returning JSON blobs from a model, being produced every minute. Refetching might mean that the data is gone.

– If we cache it, the data may be stale.
– One first principle: data must be real-time and useful.

We considered redirecting the user back to the landing page to restart the flow.

2. Not great UX?
– Redirecting users without explanation isn’t ideal.
UX and I talked about adding more copy about what happened and giving context.
– We settled on clear, simple copy to keep the UI clear.

3. Could users miss out on data?
UX was front-of-mind at this point, I wondered if a user was missing out on data?

– We debated changing the model or storing the data differently.
– We could flatten the data and store it sequentially.
– But that would impact internal calculations and they were vital.

We decided to keep the existing data architecture.

Lessons learned:
– Tradeoffs deserve time. Rushing decisions often means missing hidden costs.
– Technical choices ripple into UX and product – talk early, not under pressure.
– First principles matter. They keep decisions honest.

If you’re designing flows like this, I’d love to hear what worked for you.

Hug A Platform Engineer or IT Support or Network Support Today.

Well. Within reason. Don’t get fired.

But I learned early in my career that your platform engineer, IT support, network engineer are your allies. You’ll need them one day when you’re in a bind. The quality of your relationship with them will be directly proportional to how and how much they show up for you.

If I had treated those relationships like a short-term transaction, eh, I don’t need them right now. Whatever. That day I have a production issue on a Friday at 3pm? If I invested unwisely, that same person will just wish me luck and go about their merry way.

But if I invest in the relationship with the long game in mind, that same engineer may sit next to me while a pod is burning like a raccoon-infested dumpster fire.

Nitpicks in PR Reviews

What’s your relationship with nitpicks in PR reviews?

I’ve noticed a shift in how I respond to them. Where I used to feel defensive and nervous that I’ve missed something obvious, I now see them as part of a conversation about quality — not just correctness.

A coworker once explained his approach to PRs: “Could this be clearer? More consistent? Easier to understand?” That framing helped shift how I think about nitpicks.

Instead of feeling like threats, they’ve started to feel useful. I’ve found myself recently remembering past suggestions and folding them into my work before they’re even mentioned again.

Hable Con El

This week, I’ve been paying closer attention to how I talk about LLMs.

One change I’ve made: I stopped using product names or referring to them like they’re people. Instead of saying something like “ChatGPT suggested…”, I just say ‘an LLM’, as in: “I consulted an LLM” or “This is from an LLM.” It’s a tool, not a person. And once I start treating it like a person, I start assigning it human traits—like assuming it won’t make mistakes.

That shift came into focus during a conversation with a friend, who’s also an engineer. I mentioned that a code snippet from an LLM had a bug, and caught myself feeling oddly surprised. My coworker joked, “Don’t you introduce bugs too?” That snapped it into place—I’d let some unconscious expectations creep in.

I also started changing how I write about LLMs. Saying “This is what an LLM produced” or “Here’s a suggestion from an LLM” helps me keep the right level of distance. It reminds me that I’m still the one responsible for what goes in, what comes out, and how it’s used. The tool doesn’t absolve me of accountability.

Curious if others have noticed similar things. How do you talk about or work with LLMs and AI tools day-to-day?

Verified by ExactMetrics