DEV Community

James Nguyen
James Nguyen

Posted on

My .NET Docker image was 900MB - here's how I fixed it (and what I got wrong with JWT)

I'm a CS student working on a side project - a .NET 8 REST API for a healthcare management system we're building at university. Nothing fancy, just something to practice real-world backend stuff.

Last week I had two moments where I genuinely felt dumb. Sharing them here so maybe someone else doesn't waste the same 3 hours I did.


The Docker problem: why is my image 900MB??

I followed a basic tutorial to containerize my API. Dockerfile looked like this:

FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o out
ENTRYPOINT ["dotnet", "out/MyApp.dll"]
Enter fullscreen mode Exit fullscreen mode

Pushed it. Image was 912MB. For a simple CRUD API. That felt wrong.

After some digging (and a stackoverflow rabbit hole), I found out the issue: I was using the full SDK image as the final image. The SDK includes compilers, build tools, everything - stuff you don't need at runtime.

The fix is a multi-stage build:

# Stage 1: build the app
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish

# Stage 2: run the app (much lighter image)
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
Enter fullscreen mode Exit fullscreen mode

Final image size: 108MB.

The concept is simple once you get it: Stage 1 does the heavy lifting (compiling), Stage 2 only takes the compiled output. The SDK never makes it into the final image.

One more thing that helped - adding .dockerignore:

bin/
obj/
.vs/
*.user
Enter fullscreen mode Exit fullscreen mode

Without this, Docker copies your entire local build artifacts into the image context. Wasted time and space.


The JWT thing that could've been a security hole

While building auth, I saw a lot of tutorials store JWT in localStorage:

localStorage.setItem('token', response.data.token)
Enter fullscreen mode Exit fullscreen mode

I did this at first too. Then someone in my team pointed out: localStorage is readable by any JS running on the page. If there's an XSS vulnerability anywhere in your app, attacker can just do localStorage.getItem('token') and steal sessions.

The safer approach in .NET is setting the token as an httpOnly cookie:

Response.Cookies.Append("access_token", token, new CookieOptions
{
    HttpOnly = true,
    Secure = true,
    SameSite = SameSiteMode.Strict,
    Expires = DateTime.UtcNow.AddMinutes(15)
});
Enter fullscreen mode Exit fullscreen mode

httpOnly means JavaScript literally cannot access the cookie. Browser sends it automatically with each request, but no script can read its value.

For our university project this probably doesn't matter much. But if I'm ever building something real, I want the habit to already be there.


Stuff I'm still figuring out

  • How to handle token refresh properly in a React SPA when using httpOnly cookies
  • Whether Alpine-based images cause issues in production (heard some gotchas about missing native libs)
  • Best way to manage secrets in Docker without hardcoding them

If anyone has dealt with these, I'd genuinely appreciate thoughts in the comments.


This is part of a series where I write up small things I actually ran into while building projects. Not tutorials - just "I got stuck here, here's what worked."

Top comments (2)

Collapse
 
godaddy_llc_4e3a2f1804238 profile image
GoDaddy LLC

This is actually the kind of learning that sticks long-term — not “perfect tutorial” knowledge, but the painful 3-hour debugging sessions we never forget 😄. The Docker image issue is a classic beginner trap, and honestly, half the industry learned multi-stage builds the exact same way.

Good catch on the JWT storage too. A lot of tutorials still normalize localStorage because it’s convenient, but building secure habits early matters more than people realize. For React + httpOnly cookies, the usual pattern is short-lived access tokens with refresh handled through a secure cookie endpoint and silent refresh flow.

And yes, Alpine is great for smaller images, but native dependencies can occasionally become “surprise side quests” in production. Really solid write-up overall — practical posts like this help people more than polished “everything worked perfectly” tutorials ever do.

Collapse
 
circuit profile image
Rahul S

The silent refresh pattern is solid but worth noting it trades one attack surface for another. Moving JWT from localStorage to httpOnly cookies closes the XSS window but opens the CSRF door — SameSite=Strict helps a lot, though it doesn't protect against same-site attacks (any subdomain on the same registrable domain can still send credentialed requests). The thing that catches people off guard is the refresh token itself: if the refresh endpoint isn't bound to a device fingerprint or session ID, a leaked refresh token gives an attacker indefinite access since every refresh mints a fresh access token. Rotation helps (invalidate the old refresh token on each use), but you need server-side tracking to detect reuse — which means you're basically reimplementing sessions anyway, just with extra steps.