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.

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
Verified by ExactMetrics