DEV Community

Cover image for Angular 21 + Spring Boot 3.4 in Docker: the plumbing nobody shows you
Roman Zukov
Roman Zukov

Posted on

Angular 21 + Spring Boot 3.4 in Docker: the plumbing nobody shows you

Every time I spin up a new Angular + Spring Boot + SQL Server stack in Docker I hit the same two headaches in the first ten minutes:
1) The backend container boots faster than MSSQL, Hibernate can't connect, and Spring Boot dies before the database has even finished printing its ASCII banner.
2) The frontend can talk to the backend on "localhost:8080" from my laptop, but the moment it's inside a container, "localhost" means "myself", not "the API over there". Cue CORS errors, a refresh on /dashboard that returns 404, and a JWT flow that silently logs the user out.
Here's how this repo actually solves both without a 400-line YAML file or a BehaviorSubject-based token queue that looks smart on a blog post and breaks in production.

  • the MSSQL startup race The naive "depends_on: - db" in Compose only waits for the container to "exist". It does not wait for SQL Server to actually accept connections. SQL Server 2022 takes a solid 15–20 seconds on a cold boot, and Spring Boot's default datasource will just throw and exit. The solution is a real healthcheck plus the longer form of "depends_on":
yaml
services:
  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_SA_PASSWORD=${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$${DB_PASSWORD}\" -C -Q 'SELECT 1' || exit 1"]
      interval: 10s
      retries: 10
      start_period: 20s
      timeout: 5s

  backend:
    build: .
    depends_on:
      db:
        condition: service_healthy
    restart: on-failure

Enter fullscreen mode Exit fullscreen mode

A few things worth calling out, because each one is a footgun on its own:
1) "mssql-tools18" (not "mssql-tools"). The old path silently disappeared in the 2022 image. If you copy-pasted a healthcheck from a 2019 Stack Overflow answer, it's broken right now.

  • The "-C" flag on "sqlcmd" means "trust the server cert". Without it, the healthcheck fails with a TLS error against the self-signed cert the container ships with, and you'll spend an hour thinking your password is wrong.
  • "$${DB_PASSWORD}" — the double dollar is not a typo. A single "$" gets eaten by Compose variable substitution before the shell inside the container ever sees it.
  • "start_period: 20s" tells Docker "don't count failures for the first 20 seconds". Without it, the 10 retries burn out during the normal SQL Server boot and the container is marked unhealthy forever.
  • "restart: on-failure" on the backend is the belt-and-suspenders bit. If the DB hiccups later, Spring Boot crashes cleanly and Docker brings it back.

  • making Angular and Spring Boot pretend they're the same origin
    Inside the compose network, "http://backend:8080" works because of Docker's DNS. From a browser on your laptop, it does not — the browser has no idea what "backend" is. You could plaster CORS annotations all over your Spring controllers, but then you also need to deal with SPA deep-linking (refresh on "/users/42" and nginx returns 404 because that file doesn't exist).

The single nginx config that solves both:

nginx
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

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

    location /api {
        proxy_pass http://backend:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}
Enter fullscreen mode Exit fullscreen mode

Two blocks, one idea: from the browser's perspective, everything is served from "http://localhost:8081". No CORS headers needed on the Spring side — there is no cross-origin request anymore. The "/api" prefix is just a path; nginx swaps it for the container-internal "http://backend:8080" using Docker's service DNS.

The "try_files" line is the Angular router survival kit. Hit "/users/42", nginx checks if the file exists (it doesn't), falls through to "/index.html", and Angular's router takes over on the client. Without it, every refresh outside the root URL is a 404.

Note what's "not" here: no "/api/" with a trailing slash trying to strip the prefix, no rewrite rules. The backend is already mounted at "/api" in Spring, so the path passes through untouched. One less thing to misconfigure.

  • The JWT retry, without the fake clever Now the interceptor. If you've written more than one Angular app against a JWT backend, you've seen the 200-line "token refresh queue" pattern with a "BehaviorSubject", a "isRefreshing" flag, and a "filter(token => token !== null)". It's copy-pasted everywhere because one guy wrote it in 2019 and now it's load-bearing gospel.

For a single-tab app talking to one backend, you usually don't need it. Here's what actually ships:

ts
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = localStorage.getItem('token');

  const authReq = token
    ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : req;

  return next(authReq).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401 && !req.url.includes('/auth/')) {
        const authService = inject(AuthService);
        return authService.refreshTokens().pipe(
          switchMap((newToken: string) => {
            const retried = req.clone({
              setHeaders: { Authorization: `Bearer ${newToken}` }
            });
            return next(retried);
          }),
          catchError(() => throwError(() => error))
        );
      }
      return throwError(() => error);
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

The two lines that matter:

1) "!req.url.includes('/auth/')" — without this guard, a failed "/auth/refresh" call returns 401, the interceptor tries to refresh tokens, that call returns 401, interceptor tries again, and you've bricked the app with an infinite loop the first time a refresh token expires. Ask me how I know.
2) The inner "catchError(() => throwError(() => error))" re-throws the "original" 401, not the refresh error. The UI layer was already set up to handle 401 as "kick to login" — keep that contract, don't surface a weird refresh-endpoint error it's never seen.

"HttpInterceptorFn" is the functional API, which is the one you want on Angular 21. "inject()" inside the "catchError" works because interceptors run inside Angular's injection context. Note the lazy inject — "AuthService" is only pulled in when a 401 actually fires, which avoids a circular DI problem if your "AuthService" itself uses "HttpClient" (it does, that's how it refreshes).

Yes, in a multi-tab app hammering the API with parallel requests right as the token expires, this can fire multiple refreshes. For 99% of apps, the refresh endpoint returns the same new token and the extra calls are a rounding error. Fix it when it shows up in the logs, not before.


All the plumbing above — the healthcheck, the nginx proxy, the interceptor, plus the Spring Security config and the Angular auth flow that sits behind it — is part of a free open-source SaaS starter kit I maintain. Clone it, run "docker compose up", and you have a working stack in about 90 seconds.

https://github.com/zukovlabs/enterprise-java-saas-starter-kit

Top comments (11)

Collapse
 
buildbasekit profile image
buildbasekit

This hits the exact pain most people underestimate with Docker setups.

That MSSQL startup race and “localhost inside container” issue has wasted so many hours.

Really like the nginx approach to avoid CORS entirely instead of patching it everywhere.

Curious, have you seen this setup break when scaling beyond a single backend instance or still holding up well?

Collapse
 
zukovlabs profile image
Roman Zukov

Thanks! Yeah the MSSQL race condition alone probably cost me more debugging hours than I'd like to admit.
On scaling, this setup is intentionally single-instance because that's where 90% of projects live for their first year (or longer). The nginx reverse proxy doesn't pin you to one backend though. When you need to scale, the move is - put a load balancer in front, run multiple backend containers, and make sure your JWT validation is stateless (it already is no server-side session store). The only thing that changes in the compose file is adding replicas or switching to Kubernetes.

The part that actually breaks first at scale isn't the backend it's the database. MSSQL on a single container with no read replicas becomes the bottleneck before your Spring Boot instances do. That's when you start looking at connection pooling tuning and read replicas, not more app servers.

But let's be real, if you're at the point where a single Spring Boot instance can't handle the load, you're probably making enough money to hire someone to solve that problem.

Collapse
 
buildbasekit profile image
buildbasekit

Yeah that makes sense, especially the point about DB becoming the bottleneck first. Most people try to scale app containers way too early.

I like that this setup doesn’t over-engineer from day one.

Have you had to tweak anything around connection pooling (like Hikari settings) once traffic started increasing, or was default config enough for a while?

I’ve seen cases where that becomes the first “invisible” issue before teams even realize the DB is struggling.

Thread Thread
 
zukovlabs profile image
Roman Zukov

Yeah, that "invisible" issue is painfully real.
HikariCP defaults are actually surprisingly good for most workloads Spring Boot auto-configures it with a pool size of 10, which handles way more concurrent requests than people usually expect.

What I typically see happen: the app feels fine under normal load, but during a traffic spike, all 10 connections get checked out, new requests queue up, and response times silently jump from 50ms to 5 seconds. No errors in the logs, just slow. Users bounce before you even notice what's wrong.

If I'm tweaking anything early on, it's usually dropping connectionTimeout from the generous 30s default down to 5s (so you get a fast failure instead of a slow death) and maybe bumping maximumPoolSize to 20-25.

But the starter kit ships with the default Hikari config on purpose. It's much better to start with known defaults and tune based on actual Actuator metrics than to ship "optimized" settings cargo-culted from a random blog post about a completely different workload.

Thread Thread
 
buildbasekit profile image
buildbasekit

That’s a really interesting direction, especially the idea of a decision-support layer.

What stands out to me is most devs don’t lack tools, they lack clarity on what to optimize and when. They only notice issues once things slow down in production.

That’s where your work could be powerful inside something like BuildBaseKit.

Instead of being a separate system, it could sit closer to the backend:

  • collect real usage data (CPU, memory, request patterns)
  • detect inefficiencies early
  • suggest simple actions like config tweaks or scaling decisions

Kind of like giving developers “early signals” before things break.

I’m already seeing patterns like connection pool saturation and resource spikes becoming invisible issues in real apps , so having something proactive there would be genuinely useful.

Curious, have you tested your framework on any real backend workload yet, or still mostly at research level?

Thread Thread
 
zukovlabs profile image
Roman Zukov

Yeah, getting proactive signals before things break in production is definitely the goal. Right now, honestly, I mostly just rely on standard application logs and basic monitoring.
To answer your question: yes, this is 100% production-tested. The whole reason I built this boilerplate is that I kept hitting these exact infrastructure issues with real users on my own SaaS projects. I just got tired of configuring the same architecture from scratch every time.

Thread Thread
 
buildbasekit profile image
buildbasekit

That’s solid. Production-tested makes a big difference.

Curious, what was the first real issue you hit once actual users started hitting the system?

Was it DB pressure, connection pool limits, or something else that didn’t show up locally?

Thread Thread
 
zukovlabs profile image
Roman Zukov

The first thing that bit me wasn't DB pressure or connection pools-it was the JWT refresh race condition I mentioned in the article. Locally you never notice it because you're one user in one tab. In production, someone leaves the app open for an hour, the token expires, and then they click a dashboard that fires 4 API calls simultaneously. All 4 get a 401, all 4 try to refresh, and depending on timing you get duplicate refresh token usage which invalidates the session entirely. User gets kicked to login for no apparent reason.

The second one was the MSSQL startup race under load. Locally, Docker always has the image cached and MSSQL boots in 10 seconds. On a fresh CI/CD deploy with a cold pull, it took 45+ seconds - way past the healthcheck retries I originally configured. Had to bump start period and retries to handle that.

Both are the kind of issues that only show up when real humans are using the app on unpredictable hardware. That's why I'm pretty opinionated about including the healthcheck and interceptor setup in the starter - they're not "nice to have", they're the first two things that break.

Thread Thread
 
buildbasekit profile image
buildbasekit

That JWT refresh race is a perfect example of what usually gets missed early.

Most boilerplates handle structure, but not these real-world failure cases that only show up with actual users.

That’s the gap I’ve been focusing on with BuildBaseKit, baking in those “first things that break” so people don’t discover them the hard way in production.

Thread Thread
 
zukovlabs profile image
Roman Zukov

Appreciate the thoughtful thread. You're right that most boilerplates focus on structure the real-world failure modes only become visible once you've shipped something and watched it break in ways the dev environment never showed you. That's honestly why I started extracting the fixes I keep rediscovering into a reusable starter instead of copy-pasting them between projects.

If anyone else reading this has hit similar production footguns worth documenting, drop them in the commentsю I always interested in what breaks in other people's stacks.

Thread Thread
 
buildbasekit profile image
buildbasekit

That JWT race condition is such a real one.
Shows how most issues don’t appear until real users hit the system.

This is exactly the gap I’m focusing on with BuildBaseKit, covering those early failure points, not just scaffolding.