DEV Community

Alexis
Alexis

Posted on

DNS Automation for Multi-Tenant SaaS on CloudFlare

TL;DR: The Cloudflare.NET SDK lets you automate DNS for multi-tenant apps: provision tenant subdomains, onboard customer vanity domains with automatic SSL, and migrate zones via BIND import/export, all from C# with full IntelliSense.


If you're building a multi-tenant SaaS application, you've probably hit this wall: DNS changes don't scale with manual dashboard clicks.

Every new tenant needs a subdomain. Every enterprise customer wants their vanity domain. Every migration involves exporting zone files and hoping nothing breaks. And somewhere along the way, DNS became the one piece of infrastructure that isn't automated.

The Cloudflare.NET SDK brings DNS management into your .NET codebase. This article covers three common scenarios.


Why a Typed SDK?

A typed SDK gives you:

  • IntelliSense everywhere - Method signatures, parameter types, and return types are discoverable in your IDE
  • Compile-time safety - Catch typos and type mismatches before runtime
  • Comprehensive XML documentation - Every method, parameter, and model is documented inline
  • Online API reference - Searchable documentation with examples

Compare this to shelling out to CLI tools or writing raw HTTP requests. When you're managing hundreds of DNS records, the compiler catching a typo beats debugging a 400 response at 2am.


Scenario 1: Multi-Tenant Subdomain Provisioning

The Problem

You're building a SaaS platform where each tenant gets a subdomain:

  • acme.yourapp.com
  • globex.yourapp.com
  • initech.yourapp.com

When a customer signs up, you need to create their DNS record automatically. When they cancel, you need to clean it up. Doing this manually doesn't scale past a dozen tenants.

The Solution

Automate DNS provisioning as part of your tenant onboarding flow:

public class TenantProvisioningService(
    ICloudflareApiClient cloudflare,
    ILogger<TenantProvisioningService> logger)
{
    private readonly string _zoneId = "your-zone-id";
    private readonly string _baseDomain = "yourapp.com";
    private readonly string _appServerIp = "203.0.113.50";

    public async Task<TenantProvisioningResult> ProvisionTenantAsync(string tenantSlug)
    {
        var subdomain = $"{tenantSlug}.{_baseDomain}";

        logger.LogInformation("Provisioning DNS for tenant: {Subdomain}", subdomain);

        // Create the A record pointing to your application server
        var record = await cloudflare.Dns.CreateDnsRecordAsync(_zoneId,
            new CreateDnsRecordRequest(
                Type: DnsRecordType.A,
                Name: subdomain,
                Content: _appServerIp,
                Proxied: true,  // Enable Cloudflare's CDN and security
                Ttl: 1          // Automatic TTL when proxied
            ));

        logger.LogInformation("Created DNS record {Id} for {Subdomain}", record.Id, subdomain);

        return new TenantProvisioningResult(
            TenantSlug: tenantSlug,
            Subdomain: subdomain,
            DnsRecordId: record.Id,
            IsProxied: record.Proxied
        );
    }

    public async Task DeprovisionTenantAsync(string tenantSlug, string dnsRecordId)
    {
        logger.LogInformation("Removing DNS for tenant: {TenantSlug}", tenantSlug);

        await cloudflare.Dns.DeleteDnsRecordAsync(_zoneId, dnsRecordId);

        logger.LogInformation("Deleted DNS record {Id}", dnsRecordId);
    }
}

public record TenantProvisioningResult(
    string TenantSlug,
    string Subdomain,
    string DnsRecordId,
    bool IsProxied
);
Enter fullscreen mode Exit fullscreen mode

Batch Provisioning

Onboarding a batch of tenants? Use the batch API for atomic operations:

public async Task ProvisionTenantsAsync(IEnumerable<string> tenantSlugs)
{
    var createRequests = tenantSlugs
        .Select(slug => new CreateDnsRecordRequest(
            Type: DnsRecordType.A,
            Name: $"{slug}.{_baseDomain}",
            Content: _appServerIp,
            Proxied: true))
        .ToList();

    // All records created atomically - if one fails, none are created
    var result = await cloudflare.Dns.BatchDnsRecordsAsync(_zoneId,
        new BatchDnsRecordsRequest(Posts: createRequests));

    logger.LogInformation("Batch created {Count} tenant DNS records", result.Posts?.Count ?? 0);
}
Enter fullscreen mode Exit fullscreen mode

The batch API executes operations in a guaranteed order (Deletes → Patches → Puts → Posts) within a single database transaction. If any operation fails, the entire batch rolls back.

Find and Update Existing Records

Need to update a tenant's DNS (e.g., migrating to a new server)?

public async Task UpdateTenantIpAsync(string tenantSlug, string newIpAddress)
{
    var subdomain = $"{tenantSlug}.{_baseDomain}";

    // Find the existing record by hostname
    var existing = await cloudflare.Dns.FindDnsRecordByNameAsync(
        _zoneId,
        subdomain,
        type: DnsRecordType.A);

    if (existing is null)
    {
        throw new InvalidOperationException($"No DNS record found for {subdomain}");
    }

    // Patch only the content field
    await cloudflare.Dns.PatchDnsRecordAsync(_zoneId, existing.Id,
        new PatchDnsRecordRequest(Content: newIpAddress));

    logger.LogInformation("Updated {Subdomain} to point to {Ip}", subdomain, newIpAddress);
}
Enter fullscreen mode Exit fullscreen mode

Scenario 2: White-Label Custom Domains (Cloudflare for SaaS)

The Problem

Your enterprise customers want their own vanity domains:

  • Instead of acme.yourapp.com, Acme Corp wants app.acme.com
  • This requires SSL certificates for domains you don't control
  • You need to validate domain ownership before issuing certificates
  • The customer needs clear instructions for their DNS changes

Cloudflare for SaaS handles this. The SDK provides full access to the Custom Hostnames API.

The Solution

Implement a custom domain onboarding workflow:

public class CustomDomainService(
    ICloudflareApiClient cloudflare,
    ILogger<CustomDomainService> logger)
{
    private readonly string _zoneId = "your-zone-id";
    private readonly string _fallbackOrigin = "origin.yourapp.com";

    public async Task<CustomDomainOnboardingResult> StartOnboardingAsync(
        string customerId,
        string customerDomain)
    {
        logger.LogInformation(
            "Starting custom domain onboarding for {Customer}: {Domain}",
            customerId, customerDomain);

        // Configure SSL with TXT-based domain control validation
        var sslConfig = new SslConfiguration(
            Method: DcvMethod.Txt,
            Type: CertificateType.Dv,
            Settings: new SslSettings(
                MinTlsVersion: MinTlsVersion.Tls12,
                Http2: SslToggle.On
            )
        );

        // Create the custom hostname
        var hostname = await cloudflare.Zones.CustomHostnames.CreateAsync(_zoneId,
            new CreateCustomHostnameRequest(
                Hostname: customerDomain,
                Ssl: sslConfig
            ));

        logger.LogInformation(
            "Created custom hostname {Id} with status {Status}",
            hostname.Id, hostname.Status);

        // Build customer instructions
        var instructions = BuildCustomerInstructions(hostname);

        return new CustomDomainOnboardingResult(
            CustomHostnameId: hostname.Id,
            Status: hostname.Status.ToString(),
            SslStatus: hostname.Ssl.Status.ToString(),
            CustomerInstructions: instructions
        );
    }

    private CustomerDnsInstructions BuildCustomerInstructions(CustomHostname hostname)
    {
        var instructions = new CustomerDnsInstructions();

        // TXT record for domain ownership verification
        if (hostname.OwnershipVerification is not null)
        {
            instructions.OwnershipTxtRecord = new DnsInstruction(
                Type: "TXT",
                Name: hostname.OwnershipVerification.Name,
                Value: hostname.OwnershipVerification.Value,
                Purpose: "Proves you own this domain"
            );
        }

        // TXT record for SSL certificate validation
        if (hostname.Ssl.ValidationRecords is { Count: > 0 })
        {
            var sslValidation = hostname.Ssl.ValidationRecords[0];

            if (sslValidation.TxtName is not null)
            {
                instructions.SslTxtRecord = new DnsInstruction(
                    Type: "TXT",
                    Name: sslValidation.TxtName,
                    Value: sslValidation.TxtValue!,
                    Purpose: "Validates SSL certificate issuance"
                );
            }
        }

        // CNAME to route traffic (after validation completes)
        instructions.CnameRecord = new DnsInstruction(
            Type: "CNAME",
            Name: hostname.Hostname,
            Value: _fallbackOrigin,
            Purpose: "Routes traffic through Cloudflare to your app"
        );

        return instructions;
    }
}

public record CustomDomainOnboardingResult(
    string CustomHostnameId,
    string Status,
    string SslStatus,
    CustomerDnsInstructions CustomerInstructions
);

public record CustomerDnsInstructions
{
    public DnsInstruction? OwnershipTxtRecord { get; set; }
    public DnsInstruction? SslTxtRecord { get; set; }
    public DnsInstruction? CnameRecord { get; set; }
}

public record DnsInstruction(string Type, string Name, string Value, string Purpose);
Enter fullscreen mode Exit fullscreen mode

Poll for Validation Status

After the customer adds their DNS records, poll for validation (add to CustomDomainService):

public async Task<CustomDomainStatus> CheckValidationStatusAsync(string customHostnameId)
{
    var hostname = await cloudflare.Zones.CustomHostnames.GetAsync(_zoneId, customHostnameId);

    var result = new CustomDomainStatus(
        Hostname: hostname.Hostname,
        OverallStatus: hostname.Status,
        SslStatus: hostname.Ssl.Status,
        IsFullyActive: hostname.Status == CustomHostnameStatus.Active
                       && hostname.Ssl.Status == SslStatus.Active,
        VerificationErrors: hostname.VerificationErrors
    );

    if (result.IsFullyActive)
    {
        logger.LogInformation("Custom domain {Hostname} is fully active!", hostname.Hostname);
    }
    else if (hostname.VerificationErrors is { Count: > 0 })
    {
        logger.LogWarning(
            "Validation errors for {Hostname}: {Errors}",
            hostname.Hostname,
            string.Join(", ", hostname.VerificationErrors));
    }

    return result;
}

public record CustomDomainStatus(
    string Hostname,
    CustomHostnameStatus OverallStatus,
    SslStatus SslStatus,
    bool IsFullyActive,
    IReadOnlyList<string>? VerificationErrors
);
Enter fullscreen mode Exit fullscreen mode

List All Custom Domains

Enumerate all custom hostnames with automatic pagination (add to CustomDomainService):

public async Task<IReadOnlyList<CustomDomainSummary>> ListAllCustomDomainsAsync()
{
    var domains = new List<CustomDomainSummary>();

    await foreach (var hostname in cloudflare.Zones.CustomHostnames.ListAllAsync(_zoneId))
    {
        domains.Add(new CustomDomainSummary(
            Id: hostname.Id,
            Hostname: hostname.Hostname,
            Status: hostname.Status,
            SslStatus: hostname.Ssl.Status,
            CreatedAt: hostname.CreatedAt
        ));
    }

    return domains;
}

public record CustomDomainSummary(
    string Id,
    string Hostname,
    CustomHostnameStatus Status,
    SslStatus SslStatus,
    DateTimeOffset? CreatedAt
);
Enter fullscreen mode Exit fullscreen mode

Scenario 3: DNS Zone Migration via BIND Import/Export

The Problem

You're migrating a domain to Cloudflare from another DNS provider. You have hundreds of DNS records. Manual recreation is error-prone and tedious.

Or you're moving away from Cloudflare and need to export your zone file.

The Solution

The SDK supports BIND zone file import/export - the industry standard for DNS record interchange.

Export from Cloudflare

// Standalone helper or add to a service class
public async Task ExportZoneToFileAsync(string zoneId, string outputPath)
{
    logger.LogInformation("Exporting DNS zone {ZoneId} to BIND format", zoneId);

    var bindContent = await cloudflare.Dns.ExportDnsRecordsAsync(zoneId);

    await File.WriteAllTextAsync(outputPath, bindContent);

    logger.LogInformation("Exported zone to {Path}", outputPath);
}
Enter fullscreen mode Exit fullscreen mode

The exported file looks like standard BIND format:

;;
;; Domain:     example.com.
;; Exported:   2025-01-15 10:30:00
;;

$ORIGIN example.com.
$TTL 300

@       IN  A       203.0.113.50
www     IN  CNAME   example.com.
api     IN  A       203.0.113.51
mail    IN  MX  10  mail.example.com.
Enter fullscreen mode Exit fullscreen mode

Import to Cloudflare

// Standalone helper or add to a service class
public async Task ImportZoneFromFileAsync(string zoneId, string inputPath, bool proxied = false)
{
    logger.LogInformation("Importing DNS zone from {Path}", inputPath);

    var bindContent = await File.ReadAllTextAsync(inputPath);

    var result = await cloudflare.Dns.ImportDnsRecordsAsync(zoneId, bindContent, proxied);

    logger.LogInformation(
        "Import complete: {Added} records added, {Parsed} total parsed",
        result.RecordsAdded,
        result.TotalRecordsParsed);
}
Enter fullscreen mode Exit fullscreen mode

Migration Workflow

Combine export and import for a complete migration:

public class DnsMigrationService(
    ICloudflareApiClient cloudflare,
    ILogger<DnsMigrationService> logger)
{
    public async Task MigrateZoneAsync(
        string sourceZoneId,
        string targetZoneId,
        bool enableProxy = false)
    {
        logger.LogInformation(
            "Migrating DNS from zone {Source} to {Target}",
            sourceZoneId, targetZoneId);

        // Step 1: Export source zone
        var bindContent = await cloudflare.Dns.ExportDnsRecordsAsync(sourceZoneId);

        logger.LogInformation("Exported {Length} bytes of BIND data", bindContent.Length);

        // Step 2: Import to target zone
        var result = await cloudflare.Dns.ImportDnsRecordsAsync(
            targetZoneId,
            bindContent,
            proxied: enableProxy);

        logger.LogInformation(
            "Migration complete: {Added}/{Parsed} records imported",
            result.RecordsAdded,
            result.TotalRecordsParsed);
    }
}
Enter fullscreen mode Exit fullscreen mode

Discover Existing Records

Moving a domain that already has DNS elsewhere? Use the scan API to discover existing records (add to DnsMigrationService):

public async Task DiscoverExistingRecordsAsync(string zoneId)
{
    logger.LogInformation("Scanning for existing DNS records...");

    // Trigger async scan
    await cloudflare.Dns.TriggerDnsRecordScanAsync(zoneId);

    // Wait for scan to complete (in production, use proper polling)
    await Task.Delay(TimeSpan.FromSeconds(10));

    // Review discovered records
    var discovered = await cloudflare.Dns.GetDnsRecordScanReviewAsync(zoneId);

    logger.LogInformation("Discovered {Count} existing records", discovered.Count);

    foreach (var record in discovered)
    {
        logger.LogInformation("  {Type} {Name} -> {Content}", record.Type, record.Name, record.Content);
    }

    // Accept all discovered records
    if (discovered.Count > 0)
    {
        var review = new DnsScanReviewRequest
        {
            // Convert DnsRecord objects to DnsScanAcceptItem for the API
            Accepts = discovered.Select(DnsScanAcceptItem.FromDnsRecord).ToList()
        };

        var result = await cloudflare.Dns.SubmitDnsRecordScanReviewAsync(zoneId, review);

        logger.LogInformation("Accepted {Count} records", result.Accepts);
    }
}
Enter fullscreen mode Exit fullscreen mode

Installation

dotnet add package Cloudflare.NET.Api
Enter fullscreen mode Exit fullscreen mode

Configuration

See the Getting Started guide for full setup instructions.

appsettings.json

{
  "Cloudflare": {
    "ApiToken": "your-api-token",
    "AccountId": "your-account-id"
  }
}
Enter fullscreen mode Exit fullscreen mode

Required Permissions

Scenario Permission Level
DNS Records DNS: Edit Zone
Custom Hostnames SSL and Certificates: Edit Zone
Custom Hostnames Cloudflare for SaaS: Edit Zone
Zone Import/Export DNS: Edit Zone

Dependency Injection Setup

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCloudflareApiClient(builder.Configuration);

builder.Services.AddScoped<TenantProvisioningService>();
builder.Services.AddScoped<CustomDomainService>();
builder.Services.AddScoped<DnsMigrationService>();
Enter fullscreen mode Exit fullscreen mode

Error Handling

The SDK provides structured exceptions for DNS and Custom Hostname operations:

try
{
    await cloudflare.Dns.CreateDnsRecordAsync(zoneId, request);
}
catch (CloudflareApiException ex) when (ex.Errors.Any(e => e.Code == 81057))
{
    // Record already exists
    logger.LogWarning("DNS record already exists: {Message}", ex.Errors.First().Message);
}
catch (CloudflareApiException ex) when (ex.Errors.Any(e => e.Code == 81058))
{
    // Invalid record content
    logger.LogError("Invalid DNS record: {Message}", ex.Errors.First().Message);
}
catch (CloudflareApiException ex)
{
    // Other API errors
    foreach (var error in ex.Errors)
    {
        logger.LogError("[{Code}] {Message}", error.Code, error.Message);
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary

Most teams have CI/CD, infrastructure-as-code, automated deployments... and then someone clicks around in the Cloudflare dashboard to add a DNS record. The Cloudflare.NET SDK fixes that:

Scenario What You Get
Multi-Tenant Subdomains Automated provisioning tied to your signup flow
Custom Domains (Cloudflare for SaaS) White-label SSL with programmatic DCV workflow
Zone Migration BIND import/export for provider transitions
Record Discovery Scan and review existing DNS before migration

The batch API runs in a single transaction, so you can delete a record and recreate it with the same name without race conditions.

Get started:

dotnet add package Cloudflare.NET.Api
Enter fullscreen mode Exit fullscreen mode

Learn more:


Cloudflare.NET is an open-source, community-maintained SDK. Contributions are welcome!

Top comments (0)