Tools: I Deployed a .NET 8 URL Shortener for Free — Here's Every Problem I Hit (2026)

Tools: I Deployed a .NET 8 URL Shortener for Free — Here's Every Problem I Hit (2026)

The Stack

The Architecture

The Free Hosting Stack

Render for the API

Supabase for PostgreSQL

Upstash for Redis

Bugs I Found During Deployment

1. ObjectDisposedException on fire-and-forget

2. Credentials leaking in error responses

3. Token expiry hardcoded in the wrong place

4. Missing attributes on a controller

Observability

Testing

GitHub Traffic After Publishing

What I'd Do Differently

Summary 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. 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. If you're trying to deploy a .NET API for free, this will save you hours. Live API: https://shrinkit-1cmp.onrender.com

GitHub: https://github.com/syedhhassan/url-shortener 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. All three have genuinely usable free tiers with no credit card tricks. This stack runs indefinitely. Before getting into the deployment problems, here's how the API is structured. Four layers, dependencies pointing strictly inward: The key design decision worth calling out: caching is implemented as a decorator on IUrlRepository, not injected into services. Using Scrutor: CachedUrlRepository 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. Render doesn't have a native .NET runtime. You need Docker. The first mistake I made was restoring the whole solution in the Dockerfile: The solution file references the tests project. Docker's build context didn't include it. Fix: restore only the API project: The full Dockerfile uses a multi-stage build — the SDK image compiles, the runtime image runs. The deployed container doesn't carry the SDK: Two things worth noting: All config comes through environment variables. ASP.NET Core maps them automatically using __ as a separator for nested keys: Supabase gives you a fully managed Postgres instance. Running EF Core migrations against it is straightforward: --startup-project is required — that's where appsettings.json and DI live. Without it, EF can't find the connection string. Now here's where I lost two hours. Supabase gives you a connection string in their dashboard. I used it. The API deployed fine but every DB request returned: The actual error in logs: TCP timeout. Render's free tier runs on IPv4. Supabase's direct connection doesn't work from IPv4-only hosts. The fix is their Session pooler, which is designed exactly for this: Note the username format: postgres.YOUR_PROJECT_REF — not just postgres. That's required for the pooler. Second problem: Npgsql 9.x dropped support for URI-format connection strings. This fails: Error: Keyword 'Host' is not supported — confusingly, that error also appears when you use Host= in the keyword format with certain Npgsql builds. Use the full keyword format with Server= or Host= consistently and make sure you're on the Session pooler. Upstash gives you a serverless Redis instance. The connection string format for StackExchange.Redis (which AddStackExchangeRedisCache uses) is not the rediss:// URI format — it's this: ssl=True is required — Upstash enforces TLS. Port 6379 not 6380. No Host= prefix. Deployment surfaces bugs that local development hides. Here are the ones I hit. The redirect endpoint logs visits in the background — you don't want users waiting on analytics: This silently discards the Task. When it runs, the HTTP request has completed, ASP.NET has disposed the DI scope, and ShortenerDbContext is gone. Result: The fix creates a new scope for the background work, independent of the request: IServiceScopeFactory is a singleton — safe to inject into a controller. The using var scope inside Task.Run gives the background work its own scoped lifetime. My ErrorHandlingMiddleware was returning ex.Message for all exceptions: 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. Fix: generic message for 500s, specific message only for known domain exceptions: Rotate your credentials immediately if you've ever returned raw exception messages from a production API. JwtTokenGenerator used _settings.ExpiryMinutes from config to sign the token. UserService independently hardcoded DateTime.UtcNow.AddMinutes(60) for the response. Two sources of truth that could silently diverge. Fix: make ITokenGenerator return a tuple: The generator owns the expiry. The service just uses what comes back: VisitController was missing both [ApiController] and [Route]. Without [ApiController], model binding and automatic 400 responses don't work. Without [Route], the endpoints have no route prefix. They were effectively unreachable. The project uses prometheus-net.AspNetCore to expose metrics and Grafana for visualization. Tracked metrics include HTTP request duration, status codes, and endpoint-level throughput. Unit tests use xUnit + Moq with mocked repositories — no database required. The test that drove the duplicate email fix: 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. Two weeks after pushing the repo with a clean README: 182 clones, 81 unique cloners — without posting anywhere. The README does the work if it explains why decisions were made, not just what was used. Use a random short code generator instead of Base62 on the DB ID. The current approach encodes the auto-increment long 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. Add integration tests. 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. Use a health check endpoint. Render's free tier pings your service to check liveness. A proper /health endpoint that checks DB and Redis connectivity would catch connection issues before users do. 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. The two things that'll catch you: Built with .NET 8 · PostgreSQL · Redis · Docker · Render · Supabase · Upstash Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

services.AddScoped<IUrlRepository, UrlRepository>(); services.Decorate<IUrlRepository, CachedUrlRepository>(); services.AddScoped<IUrlRepository, UrlRepository>(); services.Decorate<IUrlRepository, CachedUrlRepository>(); services.AddScoped<IUrlRepository, UrlRepository>(); services.Decorate<IUrlRepository, CachedUrlRepository>(); RUN dotnet restore # ❌ — fails if the tests project isn't copied RUN dotnet restore # ❌ — fails if the tests project isn't copied RUN dotnet restore # ❌ — fails if the tests project isn't copied RUN dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj RUN dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj RUN dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /app COPY solidshortener.sln . COPY src/SolidShortener.Domain/SolidShortener.Domain.csproj src/SolidShortener.Domain/ COPY src/SolidShortener.Application/SolidShortener.Application.csproj src/SolidShortener.Application/ COPY src/SolidShortener.Infrastructure/SolidShortener.Infrastructure.csproj src/SolidShortener.Infrastructure/ COPY src/SolidShortener.Api/SolidShortener.Api.csproj src/SolidShortener.Api/ RUN dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj COPY src/ src/ RUN dotnet publish src/SolidShortener.Api/SolidShortener.Api.csproj -c Release -o out FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/out . ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 ENTRYPOINT ["dotnet", "SolidShortener.Api.dll"] FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /app COPY solidshortener.sln . COPY src/SolidShortener.Domain/SolidShortener.Domain.csproj src/SolidShortener.Domain/ COPY src/SolidShortener.Application/SolidShortener.Application.csproj src/SolidShortener.Application/ COPY src/SolidShortener.Infrastructure/SolidShortener.Infrastructure.csproj src/SolidShortener.Infrastructure/ COPY src/SolidShortener.Api/SolidShortener.Api.csproj src/SolidShortener.Api/ RUN dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj COPY src/ src/ RUN dotnet publish src/SolidShortener.Api/SolidShortener.Api.csproj -c Release -o out FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/out . ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 ENTRYPOINT ["dotnet", "SolidShortener.Api.dll"] FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /app COPY solidshortener.sln . COPY src/SolidShortener.Domain/SolidShortener.Domain.csproj src/SolidShortener.Domain/ COPY src/SolidShortener.Application/SolidShortener.Application.csproj src/SolidShortener.Application/ COPY src/SolidShortener.Infrastructure/SolidShortener.Infrastructure.csproj src/SolidShortener.Infrastructure/ COPY src/SolidShortener.Api/SolidShortener.Api.csproj src/SolidShortener.Api/ RUN dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj COPY src/ src/ RUN dotnet publish src/SolidShortener.Api/SolidShortener.Api.csproj -c Release -o out FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/out . ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 ENTRYPOINT ["dotnet", "SolidShortener.Api.dll"] ConnectionStrings__DefaultConnection=... ConnectionStrings__CacheConnection=... JwtSettings__SecretKey=... JwtSettings__Issuer=solidshortener JwtSettings__Audience=solidshortener-users JwtSettings__ExpiryMinutes=60 ASPNETCORE_ENVIRONMENT=Production ConnectionStrings__DefaultConnection=... ConnectionStrings__CacheConnection=... JwtSettings__SecretKey=... JwtSettings__Issuer=solidshortener JwtSettings__Audience=solidshortener-users JwtSettings__ExpiryMinutes=60 ASPNETCORE_ENVIRONMENT=Production ConnectionStrings__DefaultConnection=... ConnectionStrings__CacheConnection=... JwtSettings__SecretKey=... JwtSettings__Issuer=solidshortener JwtSettings__Audience=solidshortener-users JwtSettings__ExpiryMinutes=60 ASPNETCORE_ENVIRONMENT=Production dotnet ef database update \ --project src/SolidShortener.Infrastructure \ --startup-project src/SolidShortener.Api dotnet ef database update \ --project src/SolidShortener.Infrastructure \ --startup-project src/SolidShortener.Api dotnet ef database update \ --project src/SolidShortener.Infrastructure \ --startup-project src/SolidShortener.Api { "message": "An exception has been raised that is likely due to a transient failure." } { "message": "An exception has been raised that is likely due to a transient failure." } { "message": "An exception has been raised that is likely due to a transient failure." } at Npgsql.Internal.NpgsqlConnector.ConnectAsync System.OperationCanceledException: The operation was cancelled. at Npgsql.Internal.NpgsqlConnector.ConnectAsync System.OperationCanceledException: The operation was cancelled. at Npgsql.Internal.NpgsqlConnector.ConnectAsync System.OperationCanceledException: The operation was cancelled. 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 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 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 postgresql://user:password@host:5432/database?sslmode=require postgresql://user:password@host:5432/database?sslmode=require postgresql://user:password@host:5432/database?sslmode=require your-endpoint.upstash.io:6379,password=YOUR_PASSWORD,ssl=True,abortConnect=False your-endpoint.upstash.io:6379,password=YOUR_PASSWORD,ssl=True,abortConnect=False your-endpoint.upstash.io:6379,password=YOUR_PASSWORD,ssl=True,abortConnect=False // ❌ Wrong _ = _visitService.LogVisitAsync(command); // ❌ Wrong _ = _visitService.LogVisitAsync(command); // ❌ Wrong _ = _visitService.LogVisitAsync(command); System.ObjectDisposedException: Cannot access a disposed context instance. Object name: 'ShortenerDbContext'. System.ObjectDisposedException: Cannot access a disposed context instance. Object name: 'ShortenerDbContext'. System.ObjectDisposedException: Cannot access a disposed context instance. Object name: 'ShortenerDbContext'. // ✅ Correct _ = Task.Run(async () => { using var scope = _scopeFactory.CreateScope(); var visitService = scope.ServiceProvider.GetRequiredService<IVisitService>(); try { await visitService.LogVisitAsync(command); } catch (Exception ex) { _logger.LogError(ex, "Failed to log visit for {ShortCode}", code); } }); // ✅ Correct _ = Task.Run(async () => { using var scope = _scopeFactory.CreateScope(); var visitService = scope.ServiceProvider.GetRequiredService<IVisitService>(); try { await visitService.LogVisitAsync(command); } catch (Exception ex) { _logger.LogError(ex, "Failed to log visit for {ShortCode}", code); } }); // ✅ Correct _ = Task.Run(async () => { using var scope = _scopeFactory.CreateScope(); var visitService = scope.ServiceProvider.GetRequiredService<IVisitService>(); try { await visitService.LogVisitAsync(command); } catch (Exception ex) { _logger.LogError(ex, "Failed to log visit for {ShortCode}", code); } }); // ❌ Wrong — leaks connection strings, internal paths, everything message = ex.Message // ❌ Wrong — leaks connection strings, internal paths, everything message = ex.Message // ❌ Wrong — leaks connection strings, internal paths, everything message = ex.Message // ✅ Correct message = statusCode == HttpStatusCode.InternalServerError ? "An unexpected error occurred." : ex.Message // ✅ Correct message = statusCode == HttpStatusCode.InternalServerError ? "An unexpected error occurred." : ex.Message // ✅ Correct message = statusCode == HttpStatusCode.InternalServerError ? "An unexpected error occurred." : ex.Message public interface ITokenGenerator { (string Token, DateTime ExpiresAt) GenerateToken(UserDTO user); } public interface ITokenGenerator { (string Token, DateTime ExpiresAt) GenerateToken(UserDTO user); } public interface ITokenGenerator { (string Token, DateTime ExpiresAt) GenerateToken(UserDTO user); } var (token, expiresAt) = _tokenGenerator.GenerateToken(user); return new AuthResultDTO { Token = token, ExpiresAt = expiresAt }; var (token, expiresAt) = _tokenGenerator.GenerateToken(user); return new AuthResultDTO { Token = token, ExpiresAt = expiresAt }; var (token, expiresAt) = _tokenGenerator.GenerateToken(user); return new AuthResultDTO { Token = token, ExpiresAt = expiresAt }; app.UseHttpMetrics(); app.MapMetrics(); // exposes /metrics endpoint app.UseHttpMetrics(); app.MapMetrics(); // exposes /metrics endpoint app.UseHttpMetrics(); app.MapMetrics(); // exposes /metrics endpoint [Fact] public async Task RegisterUserAsync_DuplicateEmail_ThrowsConflict() { var existing = new User("Someone", "[email protected]", "hash"); _repoMock.Setup(r => r.GetUserByEmailAsync("[email protected]")) .ReturnsAsync(existing); await Assert.ThrowsAsync<ConflictException>(() => _sut.RegisterUserAsync(new RegisterUserCommand { Name = "Syed", Email = "[email protected]", Password = "pass" })); } [Fact] public async Task RegisterUserAsync_DuplicateEmail_ThrowsConflict() { var existing = new User("Someone", "[email protected]", "hash"); _repoMock.Setup(r => r.GetUserByEmailAsync("[email protected]")) .ReturnsAsync(existing); await Assert.ThrowsAsync<ConflictException>(() => _sut.RegisterUserAsync(new RegisterUserCommand { Name = "Syed", Email = "[email protected]", Password = "pass" })); } [Fact] public async Task RegisterUserAsync_DuplicateEmail_ThrowsConflict() { var existing = new User("Someone", "[email protected]", "hash"); _repoMock.Setup(r => r.GetUserByEmailAsync("[email protected]")) .ReturnsAsync(existing); await Assert.ThrowsAsync<ConflictException>(() => _sut.RegisterUserAsync(new RegisterUserCommand { Name = "Syed", Email = "[email protected]", Password = "pass" })); } - Domain — User, Url, Visit entities with behavior methods like MarkAsDeleted(), IncrementVisitsCount(). Zero framework dependencies. - Application — use cases, interfaces (IUrlRepository, ITokenGenerator), Commands and Queries following CQRS. Defines what happens, not how. - Infrastructure — EF Core, Redis, JWT, BCrypt. Implements Application's interfaces. - API — controllers, middleware, filters. The composition root. - Port 8080 — Render expects this on the free tier. Set via ASPNETCORE_URLS. - Copy .csproj files first — Docker caches each layer. If you copy all source first, any code change invalidates the restore cache. Copying only .csproj files first means dotnet restore only reruns when dependencies actually change. - Supabase direct connection doesn't work from IPv4 hosts — use the Session pooler - Never return ex.Message from a middleware — rotate any credentials that leaked