Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6.7 — Domain Management & TLS: IIS to Modern Hosting

For .NET engineers who know: IIS Manager SSL certificate bindings, Azure App Service custom domains, and ASP.NET Core CORS middleware You’ll learn: How TLS, custom domains, and CORS work on Render — and why most of what IIS made manual is now zero-configuration Time: 10-15 min read


The .NET Way (What You Already Know)

In IIS, configuring HTTPS for a new site is a multi-step manual process. You generate or import a certificate (PFX from a CA, or a self-signed cert for dev), bind it to the site through IIS Manager under Site Bindings, set the SNI flag if you’re running multiple sites on the same IP, and configure an HTTP-to-HTTPS redirect either through URL Rewrite rules or an additional binding. Certificate renewal is a calendar event — you renew manually every one or two years, reimport the PFX, rebind, and restart the site. Forgetting triggers a production outage accompanied by browser certificate warnings.

In Azure App Service, the experience improved: you can upload a certificate or use App Service Managed Certificates (free Let’s Encrypt integration), point a CNAME from your registrar to the .azurewebsites.net domain, and verify ownership through a TXT record. Custom domain validation uses either a CNAME verification token or an A record. HTTPS is enforced with a single toggle in the portal. It is better than IIS but still requires navigating multiple portal blades.

CORS in ASP.NET Core is configured via middleware in Program.cs, with named policies that controllers or action methods reference via [EnableCors]:

// Program.cs — ASP.NET Core CORS
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        policy.WithOrigins("https://app.example.com")
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials();
    });
});

app.UseCors("AllowFrontend");

The Modern Hosting Way

On Render, TLS is automatic and invisible. There is no certificate import, no binding dialog, no renewal reminder. Render provisions a Let’s Encrypt certificate for every custom domain the moment you add it, renews automatically before expiry, and enforces HTTPS-only by default. The operational overhead of TLS drops to approximately zero.

DNS Configuration for Render

When you add a custom domain to a Render service, the dashboard shows you exactly which DNS records to create:

For a root domain (example.com):

  • Type: A
  • Name: @ (or blank, depending on your registrar)
  • Value: 216.24.57.1 (Render’s load balancer IP — verify in your dashboard, as this can change)

Some registrars do not support ALIAS or ANAME records for root domains. In that case, use Cloudflare as your DNS provider (free tier), which supports CNAME flattening at the root. Point Cloudflare’s nameservers at your registrar, then create a CNAME pointing @ to your Render service URL.

For a subdomain (api.example.com):

  • Type: CNAME
  • Name: api
  • Value: your-service-name.onrender.com

DNS propagation typically takes 5-30 minutes, occasionally up to 24 hours for some registrars. Render’s dashboard shows a green checkmark when the domain resolves correctly and the TLS certificate is issued.

Automatic TLS

Render uses Let’s Encrypt under the hood. The lifecycle:

  1. You add a custom domain in the Render dashboard.
  2. Render validates domain ownership by checking that the DNS record points to Render’s infrastructure.
  3. Render issues a Let’s Encrypt certificate (usually within 1-2 minutes after DNS propagates).
  4. Render automatically renews the certificate 30 days before expiry.
  5. You never think about certificates again.

There is no manual intervention at any step. If the certificate fails to issue (usually because DNS has not propagated yet), Render retries automatically and shows the error state in the dashboard.

HTTP-to-HTTPS Redirects

Render enforces HTTPS at the load balancer level. HTTP requests are redirected to HTTPS automatically — no configuration needed in your NestJS or Next.js application code. Unlike IIS URL Rewrite rules or ASP.NET Core’s UseHttpsRedirection() middleware, there is nothing to configure.

You can still add UseHttpsRedirection() in NestJS if you want belt-and-suspenders behavior in development (it has no effect on Render since HTTP never reaches your application), but it is not necessary.

Custom Domains — Full Walkthrough

# Using the Render CLI to add a custom domain
render domains add api.example.com --service-id srv-xxxx

# List domains on a service
render domains list --service-id srv-xxxx

# Check TLS certificate status
render domains get api.example.com --service-id srv-xxxx

Alternatively, in the Render dashboard:

  1. Select your service
  2. Open the Custom Domains tab
  3. Click Add Custom Domain
  4. Enter the domain name
  5. Copy the DNS record Render provides
  6. Create the record in your DNS provider
  7. Click Verify — Render checks propagation and issues the certificate

Reverse Proxy Considerations

Render sits behind a load balancer, which means your NestJS application receives requests with the original client IP in the X-Forwarded-For header, not the TCP connection IP. Configure NestJS to trust the proxy:

// main.ts — trust proxy headers from Render's load balancer
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Tell Express (underlying NestJS) to trust the first proxy hop
  app.getHttpAdapter().getInstance().set('trust proxy', 1);

  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

Without this, req.ip returns the load balancer’s internal IP instead of the client IP. Rate limiting middleware, geo-restriction, and audit logging all depend on the correct client IP.

The X-Forwarded-Proto header tells your app whether the original request was HTTP or HTTPS. After setting trust proxy, Express reads this correctly, so req.secure returns true for HTTPS requests even though Render terminates TLS at the load balancer.


CORS in NestJS vs. ASP.NET Core

The conceptual model is identical — you declare allowed origins, methods, and headers, and the framework handles the preflight OPTIONS response. The implementation differs in syntax.

NestJS CORS setup in main.ts:

// main.ts — CORS configuration for NestJS
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableCors({
    // In production: specific origins only.
    // In development: allow localhost.
    origin: process.env.NODE_ENV === 'production'
      ? [
          'https://app.example.com',
          'https://www.example.com',
        ]
      : ['http://localhost:3001', 'http://localhost:5173'],

    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
    credentials: true,        // required if sending cookies or Authorization headers
    maxAge: 86400,            // cache preflight response for 24 hours
  });

  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

Dynamic origin validation (allow multiple preview URLs):

Preview environments generate unique URLs per PR. A static origin allowlist breaks every preview. Use a function to validate origins dynamically:

// main.ts — dynamic CORS origin for preview environments
app.enableCors({
  origin: (origin, callback) => {
    // Allow requests with no origin (curl, Postman, server-to-server)
    if (!origin) return callback(null, true);

    const allowedPatterns = [
      /^https:\/\/app\.example\.com$/,
      /^https:\/\/.*\.vercel\.app$/,       // all Vercel preview deployments
      /^https:\/\/.*\.onrender\.com$/,      // all Render preview deployments
      /^http:\/\/localhost:\d+$/,           // local development
    ];

    const allowed = allowedPatterns.some((pattern) => pattern.test(origin));

    if (allowed) {
      callback(null, true);
    } else {
      callback(new Error(`CORS policy: origin ${origin} is not allowed`));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
});

Per-route CORS override:

NestJS supports the @nestjs/common @Header() decorator for one-off overrides, but the idiomatic approach for per-route CORS is a custom decorator:

// Rarely needed — prefer global CORS config
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller('webhooks')
export class WebhooksController {
  @Get('stripe')
  handleStripe(@Res() res: Response) {
    // Stripe sends webhooks from their servers — no browser, no CORS.
    // If you need to handle a specific case:
    res.header('Access-Control-Allow-Origin', 'https://hooks.stripe.com');
    res.status(200).json({ received: true });
  }
}

Key Differences

IIS / Azure App ServiceRender
Certificates imported manually (PFX), renewed annuallyLet’s Encrypt, issued automatically, renewed automatically
Custom domains configured via portal UI, multiple stepsAdd domain name in dashboard, create one DNS record
HTTP redirect configured via URL Rewrite or middlewareHTTP-to-HTTPS redirect enforced at the load balancer
Client IP available in REMOTE_ADDRClient IP in X-Forwarded-For; requires trust proxy
TLS termination at the application server or ARRTLS termination at Render’s edge load balancer
CORS via middleware in Program.csCORS via app.enableCors() in main.ts
Wildcard CORS for previews requires custom middlewareDynamic origin function handles preview URLs cleanly

Gotchas for .NET Engineers

1. X-Forwarded-For must be trusted explicitly or rate limiting breaks. On Azure App Service with ARR, IP forwarding is configured at the infrastructure level. On Render, you must add app.getHttpAdapter().getInstance().set('trust proxy', 1) to main.ts. Without it, every request appears to come from the same internal Render IP address. Rate limiters keyed on client IP will throttle your entire user base after the first burst instead of per-user. This affects @nestjs/throttler, custom rate limiting, and any audit logging that records IPs.

2. CORS with credentials: true requires an explicit origin — wildcard is blocked by the spec. If you set credentials: true and origin: '*', browsers reject the response with a CORS error. The spec prohibits credentialed requests to wildcard origins. You must list specific origins or use the dynamic origin function shown above. This trips up engineers who set origin: '*' for initial development and then add cookies or Authorization headers later.

3. Let’s Encrypt certificates will not issue if DNS has not propagated. If you add a custom domain in Render immediately after creating the DNS record, Render may attempt certificate issuance before the record propagates and mark it failed. The solution: wait for DNS propagation (verify with dig api.example.com or nslookup api.example.com), then trigger re-verification from the Render dashboard. Render does retry automatically, but impatient engineers sometimes cycle through multiple attempts and get confused by cached negative results.

4. Render does not automatically configure subdomains for preview environments. Preview environments get .onrender.com URLs, not subdomains of your custom domain. If your frontend is hardcoded to https://api.example.com, it will not hit the preview API — it will hit production. The solution is to read the API URL from an environment variable (NEXT_PUBLIC_API_URL for Next.js) and set that variable to the preview environment’s .onrender.com URL in preview deployments.

5. The OPTIONS preflight request must return 204 or 200 — NestJS does this automatically but only if CORS is enabled. A common debugging trap: CORS works in Postman (no preflight), fails in the browser. Postman does not send OPTIONS preflights. If app.enableCors() is not called in main.ts, NestJS does not handle OPTIONS and returns 404. Always test CORS with an actual browser request, not Postman.


Hands-On Exercise

Set up a custom domain with automatic TLS and configure CORS for both production and preview environments.

Step 1: Add a custom domain to your Render service via the dashboard. Use a subdomain you control (api.yourname.example.com). Create the CNAME record at your DNS provider. Verify propagation with dig api.yourname.example.com until you see Render’s IP.

Step 2: Confirm TLS is issued by visiting https://api.yourname.example.com in a browser. Click the lock icon and inspect the certificate — confirm it is issued by Let’s Encrypt.

Step 3: Add trust proxy to your main.ts. Add a debug endpoint that returns req.ip and req.headers['x-forwarded-for']. Confirm the IP in the response matches your actual public IP (check against https://ifconfig.me).

Step 4: Add the dynamic CORS origin function from above, allowing *.vercel.app and *.onrender.com. Open a preview environment URL and make a credentialed API request from the browser console:

// Run in browser console from a Vercel preview URL
fetch('https://your-api.onrender.com/api/health', {
  credentials: 'include',
}).then(r => r.json()).then(console.log);

Confirm the request succeeds. Then temporarily remove *.onrender.com from the allow list and confirm it fails with a CORS error.


Quick Reference

# Add a custom domain to a Render service
render domains add api.example.com --service-id srv-xxxx

# List all custom domains on a service
render domains list --service-id srv-xxxx

# Check DNS propagation
dig api.example.com CNAME

# Check TLS certificate details
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null \
  | openssl x509 -noout -dates -issuer
// main.ts — production-ready NestJS setup
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Trust Render's load balancer
  app.getHttpAdapter().getInstance().set('trust proxy', 1);

  // CORS with dynamic origin for preview environments
  app.enableCors({
    origin: (origin, callback) => {
      if (!origin) return callback(null, true); // server-to-server
      const allowed = [
        /^https:\/\/yourdomain\.com$/,
        /^https:\/\/.*\.vercel\.app$/,
        /^https:\/\/.*\.onrender\.com$/,
        /^http:\/\/localhost:\d+$/,
      ];
      callback(
        allowed.some((p) => p.test(origin)) ? null : new Error('CORS blocked'),
        true
      );
    },
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400,
  });

  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

Further Reading