<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Roman Zukov</title>
    <description>The latest articles on DEV Community by Roman Zukov (@zukovlabs).</description>
    <link>https://dev.to/zukovlabs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3776386%2F43352b87-6da1-411a-a4f2-caf0c233157b.jpg</url>
      <title>DEV Community: Roman Zukov</title>
      <link>https://dev.to/zukovlabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zukovlabs"/>
    <language>en</language>
    <item>
      <title>Angular 21 + Spring Boot 3.4 in Docker: the plumbing nobody shows you</title>
      <dc:creator>Roman Zukov</dc:creator>
      <pubDate>Sun, 12 Apr 2026 21:16:13 +0000</pubDate>
      <link>https://dev.to/zukovlabs/angular-21-spring-boot-34-in-docker-the-plumbing-nobody-shows-you-5a7l</link>
      <guid>https://dev.to/zukovlabs/angular-21-spring-boot-34-in-docker-the-plumbing-nobody-shows-you-5a7l</guid>
      <description>&lt;p&gt;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:&lt;br&gt;
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.&lt;br&gt;
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 &lt;code&gt;/dashboard&lt;/code&gt; that returns 404, and a JWT flow that silently logs the user out.&lt;br&gt;
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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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":
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;yaml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcr.microsoft.com/mssql/server:2022-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ACCEPT_EULA=Y&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MSSQL_SA_PASSWORD=${DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/opt/mssql-tools18/bin/sqlcmd&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-S&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sa&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-P&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$${DB_PASSWORD}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-C&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-Q&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'SELECT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;

  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;on-failure&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A few things worth calling out, because each one is a footgun on its own:&lt;br&gt;
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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;"$${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.&lt;/li&gt;
&lt;li&gt;"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.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;"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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;making Angular and Spring Boot pretend they're the same origin&lt;br&gt;
Inside the compose network, "&lt;a href="http://backend:8080" rel="noopener noreferrer"&gt;http://backend:8080&lt;/a&gt;" 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).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The single nginx config that solves both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;nginx&lt;/span&gt;
&lt;span class="s"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/usr/share/nginx/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend:8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;'upgrade'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two blocks, one idea: from the browser's perspective, everything is served from "&lt;a href="http://localhost:8081" rel="noopener noreferrer"&gt;http://localhost:8081&lt;/a&gt;". 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 "&lt;a href="http://backend:8080" rel="noopener noreferrer"&gt;http://backend:8080&lt;/a&gt;" using Docker's service DNS.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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 =&amp;gt; token !== null)". It's copy-pasted everywhere because one guy wrote it in 2019 and now it's load-bearing gospel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a single-tab app talking to one backend, you usually don't need it. Here's what actually ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authInterceptor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HttpInterceptorFn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authReq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;setHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authReq&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;catchError&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HttpErrorResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AuthService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;authService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refreshTokens&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nf"&gt;switchMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="na"&gt;newToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retried&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
              &lt;span class="na"&gt;setHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;newToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retried&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}),&lt;/span&gt;
          &lt;span class="nf"&gt;catchError&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;throwError&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;throwError&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two lines that matter:&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
2) The inner "catchError(() =&amp;gt; throwError(() =&amp;gt; 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.&lt;/p&gt;

&lt;p&gt;"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).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/zukovlabs/enterprise-java-saas-starter-kit" rel="noopener noreferrer"&gt;https://github.com/zukovlabs/enterprise-java-saas-starter-kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>angular</category>
      <category>docker</category>
      <category>fullstack</category>
      <category>springboot</category>
    </item>
    <item>
      <title>Production-ready SaaS Starter Kit: Spring Boot 3.4 &amp; Angular 21 (Full Setup)</title>
      <dc:creator>Roman Zukov</dc:creator>
      <pubDate>Mon, 16 Feb 2026 21:01:10 +0000</pubDate>
      <link>https://dev.to/zukovlabs/production-ready-saas-starter-kit-spring-boot-34-angular-21-full-setup-1cfm</link>
      <guid>https://dev.to/zukovlabs/production-ready-saas-starter-kit-spring-boot-34-angular-21-full-setup-1cfm</guid>
      <description>&lt;p&gt;A production-ready SaaS baseline for Java devs to save up to 200h&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend: The Fortress
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Language:&lt;/strong&gt; Java 21 LTS (Records, Pattern Matching)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework:&lt;/strong&gt; Spring Boot 3.4.1&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security:&lt;/strong&gt; Spring Security 6 + Stateless JWT (RBAC: User, Admin, CEO)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; MSSQL 2022 (Dockerized)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORM:&lt;/strong&gt; Hibernate / Spring Data JPA&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations:&lt;/strong&gt; Flyway (Schema version control)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments:&lt;/strong&gt; Stripe Java SDK (Webhook signature verification)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing:&lt;/strong&gt; JUnit 5 &amp;amp; Mockito&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvggdswus3rfoqe4ztsfg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvggdswus3rfoqe4ztsfg.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend: Modern &amp;amp; Fast
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework:&lt;/strong&gt; Angular 21&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture:&lt;/strong&gt; Standalone Components (No NgModules), Signals, Typed Forms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI Library:&lt;/strong&gt; Angular Material 21&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State/Async:&lt;/strong&gt; RxJS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security:&lt;/strong&gt; HttpInterceptors (Token injection) &amp;amp; Auth Guards&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvoajcv1axb7ysthxn3ou.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvoajcv1axb7ysthxn3ou.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Containerization:&lt;/strong&gt; Docker &amp;amp; Docker Compose&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration:&lt;/strong&gt; Single command setup (DB + Backend + Frontend/Nginx)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This kit is designed to let you skip the setup and jump straight to writing business logic on Day 1.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvq50x34w79sx6ch9s6z3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvq50x34w79sx6ch9s6z3.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full source code is a paid product (to support development), but I have open-sourced the &lt;em&gt;Architecture Documentation&lt;/em&gt; in the README.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Check it out here:&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/zukovlabs/enterprise-java-saas-starter-kit" rel="noopener noreferrer"&gt;https://github.com/zukovlabs/enterprise-java-saas-starter-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’d love to hear your feedback on the tech stack choices!&lt;/p&gt;

</description>
      <category>java</category>
      <category>angular</category>
      <category>springboot</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
