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
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;
}
}
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);
})
);
};
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 (0)