<?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>Forem: Syed Hassan</title>
    <description>The latest articles on Forem by Syed Hassan (@maverickblaze).</description>
    <link>https://forem.com/maverickblaze</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%2F2905707%2F9669bc2e-e1b8-4375-a2ab-dd701abad9e4.jpeg</url>
      <title>Forem: Syed Hassan</title>
      <link>https://forem.com/maverickblaze</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/maverickblaze"/>
    <language>en</language>
    <item>
      <title>I Deployed a .NET 8 URL Shortener for Free — Here's Every Problem I Hit</title>
      <dc:creator>Syed Hassan</dc:creator>
      <pubDate>Fri, 17 Apr 2026 20:33:43 +0000</pubDate>
      <link>https://forem.com/maverickblaze/i-deployed-a-net-8-url-shortener-for-free-heres-every-problem-i-hit-44i0</link>
      <guid>https://forem.com/maverickblaze/i-deployed-a-net-8-url-shortener-for-free-heres-every-problem-i-hit-44i0</guid>
      <description>&lt;p&gt;I built a URL shortener API using .NET 8 and Clean Architecture, deployed it fully for free using Render + Supabase + Upstash, and hit nearly every possible problem along the way.&lt;/p&gt;

&lt;p&gt;This isn't a "here's how easy deployment is" post. This is the actual story — connection string failures, disposed DbContext exceptions, credential leaks in error responses, and a Npgsql version that silently stopped supporting URI formats.&lt;/p&gt;

&lt;p&gt;If you're trying to deploy a .NET API for free, this will save you hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live API:&lt;/strong&gt; &lt;a href="https://shrinkit-1cmp.onrender.com" rel="noopener noreferrer"&gt;https://shrinkit-1cmp.onrender.com&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/syedhhassan/url-shortener" rel="noopener noreferrer"&gt;https://github.com/syedhhassan/url-shortener&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The free tier on Render spins down after 15 minutes of inactivity. First request after sleep takes ~30 seconds. Expected behaviour — just hit the Swagger UI and wait.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API hosting&lt;/td&gt;
&lt;td&gt;Render (Web Service)&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;Upstash&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;$0/month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three have genuinely usable free tiers with no credit card tricks. This stack runs indefinitely.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Before getting into the deployment problems, here's how the API is structured.&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%2Fxuxu85x8u9neqpy5drls.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%2Fxuxu85x8u9neqpy5drls.png" alt="Architecture Diagram" width="800" height="682"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four layers, dependencies pointing strictly inward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain&lt;/strong&gt; — &lt;code&gt;User&lt;/code&gt;, &lt;code&gt;Url&lt;/code&gt;, &lt;code&gt;Visit&lt;/code&gt; entities with behavior methods like &lt;code&gt;MarkAsDeleted()&lt;/code&gt;, &lt;code&gt;IncrementVisitsCount()&lt;/code&gt;. Zero framework dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application&lt;/strong&gt; — use cases, interfaces (&lt;code&gt;IUrlRepository&lt;/code&gt;, &lt;code&gt;ITokenGenerator&lt;/code&gt;), Commands and Queries following CQRS. Defines &lt;em&gt;what&lt;/em&gt; happens, not &lt;em&gt;how&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure&lt;/strong&gt; — EF Core, Redis, JWT, BCrypt. Implements Application's interfaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API&lt;/strong&gt; — controllers, middleware, filters. The composition root.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key design decision worth calling out: &lt;strong&gt;caching is implemented as a decorator on &lt;code&gt;IUrlRepository&lt;/code&gt;&lt;/strong&gt;, not injected into services. Using Scrutor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IUrlRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UrlRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decorate&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IUrlRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CachedUrlRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CachedUrlRepository&lt;/code&gt; wraps every read with a Redis lookup and invalidates on write. Services have zero knowledge that caching exists. Swapping the caching strategy — or removing it entirely — requires no changes in Application or Domain.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Free Hosting Stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Render for the API
&lt;/h3&gt;

&lt;p&gt;Render doesn't have a native .NET runtime. You need Docker.&lt;/p&gt;

&lt;p&gt;The first mistake I made was restoring the whole solution in the Dockerfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet restore   &lt;span class="c"&gt;# ❌ — fails if the tests project isn't copied&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The solution file references the tests project. Docker's build context didn't include it. Fix: restore only the API project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full Dockerfile uses a multi-stage build — the SDK image compiles, the runtime image runs. The deployed container doesn't carry the SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/sdk:8.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; solidshortener.sln .&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; src/SolidShortener.Domain/SolidShortener.Domain.csproj src/SolidShortener.Domain/&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; src/SolidShortener.Application/SolidShortener.Application.csproj src/SolidShortener.Application/&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; src/SolidShortener.Infrastructure/SolidShortener.Infrastructure.csproj src/SolidShortener.Infrastructure/&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; src/SolidShortener.Api/SolidShortener.Api.csproj src/SolidShortener.Api/&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; src/ src/&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet publish src/SolidShortener.Api/SolidShortener.Api.csproj &lt;span class="nt"&gt;-c&lt;/span&gt; Release &lt;span class="nt"&gt;-o&lt;/span&gt; out

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/aspnet:8.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/out .&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; ASPNETCORE_URLS=http://+:8080&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["dotnet", "SolidShortener.Api.dll"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Port 8080&lt;/strong&gt; — Render expects this on the free tier. Set via &lt;code&gt;ASPNETCORE_URLS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy &lt;code&gt;.csproj&lt;/code&gt; files first&lt;/strong&gt; — Docker caches each layer. If you copy all source first, any code change invalidates the restore cache. Copying only &lt;code&gt;.csproj&lt;/code&gt; files first means &lt;code&gt;dotnet restore&lt;/code&gt; only reruns when dependencies actually change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All config comes through environment variables. ASP.NET Core maps them automatically using &lt;code&gt;__&lt;/code&gt; as a separator for nested keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ConnectionStrings__DefaultConnection=...
ConnectionStrings__CacheConnection=...
JwtSettings__SecretKey=...
JwtSettings__Issuer=solidshortener
JwtSettings__Audience=solidshortener-users
JwtSettings__ExpiryMinutes=60
ASPNETCORE_ENVIRONMENT=Production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fko9a0ufzy3iz961i2cab.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%2Fko9a0ufzy3iz961i2cab.png" alt="Render deployment dashboard" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase for PostgreSQL
&lt;/h3&gt;

&lt;p&gt;Supabase gives you a fully managed Postgres instance. Running EF Core migrations against it is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet ef database update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt; src/SolidShortener.Infrastructure &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--startup-project&lt;/span&gt; src/SolidShortener.Api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--startup-project&lt;/code&gt; is required — that's where &lt;code&gt;appsettings.json&lt;/code&gt; and DI live. Without it, EF can't find the connection string.&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%2Frvrphv9adj8ucz3l67bx.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%2Frvrphv9adj8ucz3l67bx.png" alt="Supabase Schema Visualizer showing users, urls, and visits tables" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now here's where I lost two hours.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Supabase gives you a connection string in their dashboard. I used it. The API deployed fine but every DB request returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"An exception has been raised that is likely due to a transient failure."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual error in logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;at Npgsql.Internal.NpgsqlConnector.ConnectAsync
System.OperationCanceledException: The operation was cancelled.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TCP timeout. Render's free tier runs on IPv4. Supabase's &lt;strong&gt;direct connection&lt;/strong&gt; doesn't work from IPv4-only hosts. The fix is their &lt;strong&gt;Session pooler&lt;/strong&gt;, which is designed exactly for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host=aws-1-ap-south-1.pooler.supabase.com;Port=5432;
Database=postgres;
Username=postgres.YOUR_PROJECT_REF;
Password=YOUR_PASSWORD;
SSL Mode=Require;
Trust Server Certificate=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the username format: &lt;code&gt;postgres.YOUR_PROJECT_REF&lt;/code&gt; — not just &lt;code&gt;postgres&lt;/code&gt;. That's required for the pooler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second problem&lt;/strong&gt;: Npgsql 9.x dropped support for URI-format connection strings. This fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://user:password@host:5432/database?sslmode=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Error: &lt;code&gt;Keyword 'Host' is not supported&lt;/code&gt; — confusingly, that error also appears when you use &lt;code&gt;Host=&lt;/code&gt; in the keyword format with certain Npgsql builds. Use the full keyword format with &lt;code&gt;Server=&lt;/code&gt; or &lt;code&gt;Host=&lt;/code&gt; consistently and make sure you're on the Session pooler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upstash for Redis
&lt;/h3&gt;

&lt;p&gt;Upstash gives you a serverless Redis instance. The connection string format for StackExchange.Redis (which &lt;code&gt;AddStackExchangeRedisCache&lt;/code&gt; uses) is not the &lt;code&gt;rediss://&lt;/code&gt; URI format — it's this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your-endpoint.upstash.io:6379,password=YOUR_PASSWORD,ssl=True,abortConnect=False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssl=True&lt;/code&gt; is required — Upstash enforces TLS. Port &lt;code&gt;6379&lt;/code&gt; not &lt;code&gt;6380&lt;/code&gt;. No &lt;code&gt;Host=&lt;/code&gt; prefix.&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%2Fst76362kfwxtf8cx1mor.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%2Fst76362kfwxtf8cx1mor.png" alt="Upstash Redis dashboard showing 1.1K commands" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Bugs I Found During Deployment
&lt;/h2&gt;

&lt;p&gt;Deployment surfaces bugs that local development hides. Here are the ones I hit.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. ObjectDisposedException on fire-and-forget
&lt;/h3&gt;

&lt;p&gt;The redirect endpoint logs visits in the background — you don't want users waiting on analytics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Wrong&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_visitService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogVisitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This silently discards the Task. When it runs, the HTTP request has completed, ASP.NET has disposed the DI scope, and &lt;code&gt;ShortenerDbContext&lt;/code&gt; is gone. Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System.ObjectDisposedException: Cannot access a disposed context instance.
Object name: 'ShortenerDbContext'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix creates a new scope for the background work, independent of the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Correct&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_scopeFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateScope&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;visitService&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IVisitService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;visitService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogVisitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Failed to log visit for {ShortCode}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&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;&lt;code&gt;IServiceScopeFactory&lt;/code&gt; is a singleton — safe to inject into a controller. The &lt;code&gt;using var scope&lt;/code&gt; inside &lt;code&gt;Task.Run&lt;/code&gt; gives the background work its own scoped lifetime.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Credentials leaking in error responses
&lt;/h3&gt;

&lt;p&gt;My &lt;code&gt;ErrorHandlingMiddleware&lt;/code&gt; was returning &lt;code&gt;ex.Message&lt;/code&gt; for all exceptions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Wrong — leaks connection strings, internal paths, everything&lt;/span&gt;
&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a DB connection fails, Npgsql's exception message contains the full connection string, including the password. I discovered this when a 500 response came back with my Supabase password in the body — visible to anyone with browser devtools open.&lt;/p&gt;

&lt;p&gt;Fix: generic message for 500s, specific message only for known domain exceptions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Correct&lt;/span&gt;
&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InternalServerError&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"An unexpected error occurred."&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rotate your credentials immediately if you've ever returned raw exception messages from a production API.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Token expiry hardcoded in the wrong place
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;JwtTokenGenerator&lt;/code&gt; used &lt;code&gt;_settings.ExpiryMinutes&lt;/code&gt; from config to sign the token. &lt;code&gt;UserService&lt;/code&gt; independently hardcoded &lt;code&gt;DateTime.UtcNow.AddMinutes(60)&lt;/code&gt; for the response. Two sources of truth that could silently diverge.&lt;/p&gt;

&lt;p&gt;Fix: make &lt;code&gt;ITokenGenerator&lt;/code&gt; return a tuple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ITokenGenerator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;ExpiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;GenerateToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserDTO&lt;/span&gt; &lt;span class="n"&gt;user&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 generator owns the expiry. The service just uses what comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_tokenGenerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GenerateToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AuthResultDTO&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ExpiresAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expiresAt&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Missing attributes on a controller
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;VisitController&lt;/code&gt; was missing both &lt;code&gt;[ApiController]&lt;/code&gt; and &lt;code&gt;[Route]&lt;/code&gt;. Without &lt;code&gt;[ApiController]&lt;/code&gt;, model binding and automatic 400 responses don't work. Without &lt;code&gt;[Route]&lt;/code&gt;, the endpoints have no route prefix. They were effectively unreachable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Observability
&lt;/h2&gt;

&lt;p&gt;The project uses &lt;code&gt;prometheus-net.AspNetCore&lt;/code&gt; to expose metrics and Grafana for visualization. Tracked metrics include HTTP request duration, status codes, and endpoint-level throughput.&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%2Ft7i3zcetuw5dqopfelos.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%2Ft7i3zcetuw5dqopfelos.png" alt="Grafana dashboard showing HTTP request metrics" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseHttpMetrics&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapMetrics&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// exposes /metrics endpoint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;Unit tests use xUnit + Moq with mocked repositories — no database required. The test that drove the duplicate email fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;RegisterUserAsync_DuplicateEmail_ThrowsConflict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Someone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"taken@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"hash"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;_repoMock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUserByEmailAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"taken@example.com"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
             &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReturnsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ThrowsAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ConflictException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;_sut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterUserAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;RegisterUserCommand&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Syed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"taken@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"pass"&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;Write the test first, watch it fail, add the check, watch it pass. The test existed before the fix — that's how the fix was driven.&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%2Fivxcsilzeecehgz4iauq.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%2Fivxcsilzeecehgz4iauq.png" alt="Live Swagger UI at shrinkit-1cmp.onrender.com" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  GitHub Traffic After Publishing
&lt;/h2&gt;

&lt;p&gt;Two weeks after pushing the repo with a clean README:&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%2Fv5h5kd4j1nfzm3vuqdej.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%2Fv5h5kd4j1nfzm3vuqdej.png" alt="GitHub traffic showing 182 clones and 81 unique cloners in 14 days" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;182 clones, 81 unique cloners — without posting anywhere. The README does the work if it explains &lt;em&gt;why&lt;/em&gt; decisions were made, not just &lt;em&gt;what&lt;/em&gt; was used.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use a random short code generator instead of Base62 on the DB ID.&lt;/strong&gt; The current approach encodes the auto-increment &lt;code&gt;long&lt;/code&gt; ID directly — so short codes are sequential and enumerable. Anyone can iterate through all URLs by incrementing the code. Fine for a portfolio project, not for production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add integration tests.&lt;/strong&gt; Unit tests with mocked repos are fast but they don't catch connection string issues, migration drift, or EF query translation bugs. A TestContainers setup spins up a real Postgres instance in Docker for tests — worth adding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a health check endpoint.&lt;/strong&gt; Render's free tier pings your service to check liveness. A proper &lt;code&gt;/health&lt;/code&gt; endpoint that checks DB and Redis connectivity would catch connection issues before users do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The free stack works. Render + Supabase + Upstash runs a production-quality .NET API at $0/month. The problems are all solvable — you just need to know where to look.&lt;/p&gt;

&lt;p&gt;The two things that'll catch you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Supabase direct connection doesn't work from IPv4 hosts — use the Session pooler&lt;/li&gt;
&lt;li&gt;Never return &lt;code&gt;ex.Message&lt;/code&gt; from a middleware — rotate any credentials that leaked&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full source: &lt;a href="https://github.com/syedhhassan/url-shortener" rel="noopener noreferrer"&gt;https://github.com/syedhhassan/url-shortener&lt;/a&gt;&lt;br&gt;
Live API: &lt;a href="https://shrinkit-1cmp.onrender.com" rel="noopener noreferrer"&gt;https://shrinkit-1cmp.onrender.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with .NET 8 · PostgreSQL · Redis · Docker · Render · Supabase · Upstash&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
