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.
The Stack
| Concern | Service | Cost |
|---|---|---|
| API hosting | Render (Web Service) | Free |
| PostgreSQL | Supabase | Free |
| Redis | Upstash | Free |
| Total | $0/month |
All three have genuinely usable free tiers with no credit card tricks. This stack runs indefinitely.
The Architecture
Before getting into the deployment problems, here's how the API is structured.
Four layers, dependencies pointing strictly inward:
-
Domain —
User,Url,Visitentities with behavior methods likeMarkAsDeleted(),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.
The key design decision worth calling out: caching is implemented as a decorator on IUrlRepository, not injected into services. Using Scrutor:
services.AddScoped<IUrlRepository, UrlRepository>();
services.Decorate<IUrlRepository, CachedUrlRepository>();
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.
The Free Hosting Stack
Render for the API
Render doesn't have a native .NET runtime. You need Docker.
The first mistake I made was restoring the whole solution in the Dockerfile:
RUN dotnet restore # ❌ — fails if the tests project isn't copied
The solution file references the tests project. Docker's build context didn't include it. Fix: restore only the API project:
RUN dotnet restore src/SolidShortener.Api/SolidShortener.Api.csproj
The full Dockerfile uses a multi-stage build — the SDK image compiles, the runtime image runs. The deployed container doesn't carry the SDK:
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"]
Two things worth noting:
-
Port 8080 — Render expects this on the free tier. Set via
ASPNETCORE_URLS. -
Copy
.csprojfiles first — Docker caches each layer. If you copy all source first, any code change invalidates the restore cache. Copying only.csprojfiles first meansdotnet restoreonly reruns when dependencies actually change.
All config comes through environment variables. ASP.NET Core maps them automatically using __ as a separator for nested keys:
ConnectionStrings__DefaultConnection=...
ConnectionStrings__CacheConnection=...
JwtSettings__SecretKey=...
JwtSettings__Issuer=solidshortener
JwtSettings__Audience=solidshortener-users
JwtSettings__ExpiryMinutes=60
ASPNETCORE_ENVIRONMENT=Production
Supabase for PostgreSQL
Supabase gives you a fully managed Postgres instance. Running EF Core migrations against it is straightforward:
dotnet ef database update \
--project src/SolidShortener.Infrastructure \
--startup-project src/SolidShortener.Api
--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:
{
"message": "An exception has been raised that is likely due to a transient failure."
}
The actual error in logs:
at Npgsql.Internal.NpgsqlConnector.ConnectAsync
System.OperationCanceledException: The operation was cancelled.
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:
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
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:
postgresql://user:password@host:5432/database?sslmode=require
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 for Redis
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:
your-endpoint.upstash.io:6379,password=YOUR_PASSWORD,ssl=True,abortConnect=False
ssl=True is required — Upstash enforces TLS. Port 6379 not 6380. No Host= prefix.
Bugs I Found During Deployment
Deployment surfaces bugs that local development hides. Here are the ones I hit.
1. ObjectDisposedException on fire-and-forget
The redirect endpoint logs visits in the background — you don't want users waiting on analytics:
// ❌ Wrong
_ = _visitService.LogVisitAsync(command);
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:
System.ObjectDisposedException: Cannot access a disposed context instance.
Object name: 'ShortenerDbContext'.
The fix creates a new scope for the background work, independent of the request:
// ✅ 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);
}
});
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.
2. Credentials leaking in error responses
My ErrorHandlingMiddleware was returning ex.Message for all exceptions:
// ❌ Wrong — leaks connection strings, internal paths, everything
message = ex.Message
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:
// ✅ Correct
message = statusCode == HttpStatusCode.InternalServerError
? "An unexpected error occurred."
: ex.Message
Rotate your credentials immediately if you've ever returned raw exception messages from a production API.
3. Token expiry hardcoded in the wrong place
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:
public interface ITokenGenerator
{
(string Token, DateTime ExpiresAt) GenerateToken(UserDTO user);
}
The generator owns the expiry. The service just uses what comes back:
var (token, expiresAt) = _tokenGenerator.GenerateToken(user);
return new AuthResultDTO { Token = token, ExpiresAt = expiresAt };
4. Missing attributes on a controller
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.
Observability
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.
In Program.cs:
app.UseHttpMetrics();
app.MapMetrics(); // exposes /metrics endpoint
Testing
Unit tests use xUnit + Moq with mocked repositories — no database required. The test that drove the duplicate email fix:
[Fact]
public async Task RegisterUserAsync_DuplicateEmail_ThrowsConflict()
{
var existing = new User("Someone", "taken@example.com", "hash");
_repoMock.Setup(r => r.GetUserByEmailAsync("taken@example.com"))
.ReturnsAsync(existing);
await Assert.ThrowsAsync<ConflictException>(() =>
_sut.RegisterUserAsync(new RegisterUserCommand
{ Name = "Syed", Email = "taken@example.com", Password = "pass" }));
}
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.
GitHub Traffic After Publishing
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.
What I'd Do Differently
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.
Summary
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:
- Supabase direct connection doesn't work from IPv4 hosts — use the Session pooler
- Never return
ex.Messagefrom a middleware — rotate any credentials that leaked
Full source: https://github.com/syedhhassan/url-shortener
Live API: https://shrinkit-1cmp.onrender.com
Built with .NET 8 · PostgreSQL · Redis · Docker · Render · Supabase · Upstash







Top comments (0)