WebZum Logo
WebZum

From Zero to Website Hero

Sign InSign Up
Back to Blog
awsautomationsslcloudfrontstartup

We Automated Custom Domain Setup (SSL, DNS, CloudFront) So Users Don't Have To

WebZum Team•October 18, 2025•9 min read
We Automated Custom Domain Setup (SSL, DNS, CloudFront) So Users Don't Have To

We Automated Custom Domain Setup (SSL, DNS, CloudFront) So Users Don’t Have To

TL;DR: We built a domain provisioning orchestrator that automates the entire custom domain setup: registers domains via Route53, provisions SSL certificates with ACM, creates CloudFront distributions, configures DNS records, and deploys websites—all automatically. Users click “Register Domain” and 10 minutes later their website is live on their custom domain with HTTPS.

The Problem: Custom Domains Are a Nightmare

We generate websites on subdomains (business.webzum.com). But users want their own domains (business.com).

What’s involved in custom domain setup:

  1. Register domain (Route53)
  2. Create hosted zone (Route53)
  3. Request SSL certificate (ACM)
  4. Validate domain ownership (DNS records)
  5. Wait for SSL validation (5-30 minutes)
  6. Create CloudFront distribution
  7. Configure CloudFront with SSL
  8. Point domain to CloudFront (DNS records)
  9. Deploy website files
  10. Invalidate CloudFront cache

Traditional approaches:

  • Manual setup: Users follow a 20-step guide (90% give up)
  • Third-party services: Netlify, Vercel ($20/month per domain)
  • No custom domains: Stick with subdomains (unprofessional)

The insight: If we can automate domain registration, we can automate domain provisioning.

The Breakthrough: Orchestration > Manual Steps

The breakthrough came when we realized: Every step is an AWS API call. We can orchestrate them.

Bad: Give users a guide → hope they follow it correctly Good: User clicks button → system does everything automatically

The difference? 10 minutes of automation vs 2 hours of manual work.

How It Works: The Technical Architecture

1. Domain Provisioning Orchestrator

The brain that coordinates all AWS services:

class DomainProvisioningOrchestrator {
  async provisionDomain(params: {
    businessId: string;
    domain: string;
    contactInfo: ContactInfo;
  }): Promise<ProvisioningResult> {
    
    console.log(`🚀 Starting domain provisioning for ${params.domain}`);
    
    try {
      // Step 1: Register domain with Route53
      const registration = await this.registerDomain(params);
      
      // Step 2: Create hosted zone
      const hostedZone = await this.createHostedZone(params.domain);
      
      // Step 3: Request SSL certificate
      const certificate = await this.requestSSLCertificate(params.domain);
      
      // Step 4: Add DNS validation records
      await this.addDNSValidationRecords(hostedZone.Id, certificate);
      
      // Step 5: Wait for SSL validation (polling)
      await this.waitForSSLValidation(certificate.CertificateArn);
      
      // Step 6: Create CloudFront distribution
      const distribution = await this.createCloudFrontDistribution({
        domain: params.domain,
        certificateArn: certificate.CertificateArn,
        businessId: params.businessId
      });
      
      // Step 7: Point domain to CloudFront
      await this.pointDomainToCloudFront(
        hostedZone.Id,
        params.domain,
        distribution.DomainName
      );
      
      // Step 8: Deploy website to custom domain
      await this.deployWebsite(params.businessId, params.domain);
      
      // Step 9: Update business record
      await this.updateBusinessRecord(params.businessId, {
        customDomain: {
          domain: params.domain,
          hostedZoneId: hostedZone.Id,
          certificateArn: certificate.CertificateArn,
          distributionId: distribution.Id,
          distributionDomain: distribution.DomainName,
          provisioningStatus: 'ready',
          provisionedAt: new Date()
        }
      });
      
      console.log(`✅ Domain provisioning complete for ${params.domain}`);
      
      return {
        success: true,
        domain: params.domain,
        httpsUrl: `https://${params.domain}`
      };
      
    } catch (error) {
      console.error(`❌ Domain provisioning failed:`, error);
      
      // Cleanup on failure
      await this.cleanup(params);
      
      throw error;
    }
  }
}

2. SSL Certificate Automation

Request and validate SSL certificates automatically:

async requestSSLCertificate(domain: string): Promise<Certificate> {
  const acm = new ACMClient({ region: 'us-east-1' }); // CloudFront requires us-east-1
  
  // Request certificate
  const response = await acm.send(new RequestCertificateCommand({
    DomainName: domain,
    ValidationMethod: 'DNS',
    SubjectAlternativeNames: [`www.${domain}`] // Include www subdomain
  }));
  
  console.log(`📜 SSL certificate requested: ${response.CertificateArn}`);
  
  // Get validation records
  const cert = await acm.send(new DescribeCertificateCommand({
    CertificateArn: response.CertificateArn
  }));
  
  return {
    CertificateArn: response.CertificateArn,
    ValidationRecords: cert.Certificate.DomainValidationOptions.map(option => ({
      Name: option.ResourceRecord.Name,
      Type: option.ResourceRecord.Type,
      Value: option.ResourceRecord.Value
    }))
  };
}

async addDNSValidationRecords(
  hostedZoneId: string,
  certificate: Certificate
): Promise<void> {
  const route53 = new Route53Client({ region: 'us-east-1' });
  
  // Add CNAME records for SSL validation
  const changes = certificate.ValidationRecords.map(record => ({
    Action: 'UPSERT',
    ResourceRecordSet: {
      Name: record.Name,
      Type: record.Type,
      TTL: 300,
      ResourceRecords: [{ Value: record.Value }]
    }
  }));
  
  await route53.send(new ChangeResourceRecordSetsCommand({
    HostedZoneId: hostedZoneId,
    ChangeBatch: { Changes: changes }
  }));
  
  console.log(`📝 DNS validation records added`);
}

async waitForSSLValidation(certificateArn: string): Promise<void> {
  const acm = new ACMClient({ region: 'us-east-1' });
  const maxAttempts = 60; // 30 minutes (30 second intervals)
  let attempts = 0;
  
  console.log(`⏳ Waiting for SSL validation...`);
  
  while (attempts < maxAttempts) {
    const response = await acm.send(new DescribeCertificateCommand({
      CertificateArn: certificateArn
    }));
    
    const status = response.Certificate.Status;
    
    if (status === 'ISSUED') {
      console.log(`✅ SSL certificate validated and issued`);
      return;
    }
    
    if (status === 'FAILED') {
      throw new Error('SSL certificate validation failed');
    }
    
    attempts++;
    await new Promise(resolve => setTimeout(resolve, 30000)); // Wait 30 seconds
  }
  
  throw new Error('SSL validation timeout');
}

3. CloudFront Distribution Creation

Create CloudFront distribution with custom domain and SSL:

async createCloudFrontDistribution(params: {
  domain: string;
  certificateArn: string;
  businessId: string;
}): Promise<Distribution> {
  const cloudfront = new CloudFrontClient({ region: 'us-east-1' });
  
  const response = await cloudfront.send(new CreateDistributionCommand({
    DistributionConfig: {
      CallerReference: `${params.businessId}-${Date.now()}`,
      Comment: `WebZum - ${params.domain}`,
      Enabled: true,
      
      // Origin: S3 bucket with generated website
      Origins: {
        Quantity: 1,
        Items: [{
          Id: 'S3-webzum-generated-sites',
          DomainName: 'webzum-generated-sites.s3.amazonaws.com',
          S3OriginConfig: {
            OriginAccessIdentity: `origin-access-identity/cloudfront/${OAI_ID}`
          },
          OriginPath: `/${params.businessId}/latest`
        }]
      },
      
      // Default cache behavior
      DefaultCacheBehavior: {
        TargetOriginId: 'S3-webzum-generated-sites',
        ViewerProtocolPolicy: 'redirect-to-https',
        AllowedMethods: {
          Quantity: 2,
          Items: ['GET', 'HEAD']
        },
        CachedMethods: {
          Quantity: 2,
          Items: ['GET', 'HEAD']
        },
        ForwardedValues: {
          QueryString: false,
          Cookies: { Forward: 'none' }
        },
        MinTTL: 0,
        DefaultTTL: 86400, // 1 day
        MaxTTL: 31536000 // 1 year
      },
      
      // Custom domain
      Aliases: {
        Quantity: 2,
        Items: [params.domain, `www.${params.domain}`]
      },
      
      // SSL certificate
      ViewerCertificate: {
        ACMCertificateArn: params.certificateArn,
        SSLSupportMethod: 'sni-only',
        MinimumProtocolVersion: 'TLSv1.2_2021'
      },
      
      // Default root object
      DefaultRootObject: 'index.html',
      
      // Custom error responses (for SPA routing)
      CustomErrorResponses: {
        Quantity: 1,
        Items: [{
          ErrorCode: 404,
          ResponseCode: '200',
          ResponsePagePath: '/index.html',
          ErrorCachingMinTTL: 300
        }]
      }
    }
  }));
  
  console.log(`☁️ CloudFront distribution created: ${response.Distribution.Id}`);
  
  return {
    Id: response.Distribution.Id,
    DomainName: response.Distribution.DomainName,
    Status: response.Distribution.Status
  };
}

4. DNS Configuration

Point domain to CloudFront:

async pointDomainToCloudFront(
  hostedZoneId: string,
  domain: string,
  cloudFrontDomain: string
): Promise<void> {
  const route53 = new Route53Client({ region: 'us-east-1' });
  
  // Create A record (apex domain)
  await route53.send(new ChangeResourceRecordSetsCommand({
    HostedZoneId: hostedZoneId,
    ChangeBatch: {
      Changes: [
        {
          Action: 'UPSERT',
          ResourceRecordSet: {
            Name: domain,
            Type: 'A',
            AliasTarget: {
              HostedZoneId: 'Z2FDTNDATAQYW2', // CloudFront hosted zone ID
              DNSName: cloudFrontDomain,
              EvaluateTargetHealth: false
            }
          }
        },
        {
          Action: 'UPSERT',
          ResourceRecordSet: {
            Name: `www.${domain}`,
            Type: 'A',
            AliasTarget: {
              HostedZoneId: 'Z2FDTNDATAQYW2',
              DNSName: cloudFrontDomain,
              EvaluateTargetHealth: false
            }
          }
        }
      ]
    }
  }));
  
  console.log(`🌐 DNS records configured for ${domain}`);
}

5. Polling & Status Tracking

Track provisioning status in real-time:

// Store provisioning status in DynamoDB
await db.updateBusiness(businessId, {
  customDomain: {
    domain,
    provisioningStatus: 'provisioning',
    provisioningStep: 'ssl_validation',
    provisioningProgress: 50,
    startedAt: new Date()
  }
});

// Background job polls for completion
async function pollProvisioningStatus(businessId: string) {
  const business = await db.getBusiness(businessId);
  
  if (business.customDomain?.provisioningStatus !== 'provisioning') {
    return; // Already complete or failed
  }
  
  // Check SSL certificate status
  const cert = await acm.send(new DescribeCertificateCommand({
    CertificateArn: business.customDomain.certificateArn
  }));
  
  if (cert.Certificate.Status === 'ISSUED') {
    // Continue provisioning
    await orchestrator.continueProvisioning(businessId);
  } else {
    // Still waiting, check again in 30 seconds
    setTimeout(() => pollProvisioningStatus(businessId), 30000);
  }
}

The Challenges We Solved

Challenge 1: SSL Validation Timing

Problem: SSL validation takes 5-30 minutes, can’t block the user

Solution: Background polling + status updates

// Start provisioning asynchronously
async function startProvisioning(businessId: string, domain: string) {
  // Return immediately to user
  const provisioningId = generateId();
  
  // Start background job
  provisionDomainInBackground(provisioningId, businessId, domain);
  
  return {
    provisioningId,
    status: 'started',
    estimatedTime: '10-15 minutes'
  };
}

// User can check status
async function getProvisioningStatus(provisioningId: string) {
  const status = await db.getProvisioningStatus(provisioningId);
  
  return {
    status: status.provisioningStatus,
    step: status.provisioningStep,
    progress: status.provisioningProgress,
    estimatedTimeRemaining: calculateETA(status)
  };
}

Challenge 2: Cleanup on Failure

Problem: If any step fails, we need to clean up AWS resources

Solution: Comprehensive cleanup function

async cleanup(params: { businessId: string; domain: string }) {
  console.log(`🧹 Cleaning up failed provisioning for ${params.domain}`);
  
  try {
    // Delete CloudFront distribution
    if (params.distributionId) {
      await this.deleteCloudFrontDistribution(params.distributionId);
    }
    
    // Delete SSL certificate
    if (params.certificateArn) {
      await this.deleteSSLCertificate(params.certificateArn);
    }
    
    // Delete hosted zone
    if (params.hostedZoneId) {
      await this.deleteHostedZone(params.hostedZoneId);
    }
    
    // Note: Domain registration can't be undone (AWS limitation)
    // User keeps the domain but provisioning failed
    
  } catch (error) {
    console.error('Cleanup failed:', error);
    // Log for manual cleanup
  }
}

Challenge 3: IAM Permissions

Problem: AppRunner needs extensive AWS permissions

Solution: Comprehensive IAM policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "route53:CreateHostedZone",
        "route53:DeleteHostedZone",
        "route53:ListHostedZones",
        "route53:ChangeResourceRecordSets",
        "route53:GetChange"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "acm:RequestCertificate",
        "acm:DescribeCertificate",
        "acm:DeleteCertificate",
        "acm:ListCertificates"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "cloudfront:CreateDistribution",
        "cloudfront:GetDistribution",
        "cloudfront:UpdateDistribution",
        "cloudfront:DeleteDistribution",
        "cloudfront:CreateInvalidation"
      ],
      "Resource": "*"
    }
  ]
}

The Results: 10x Faster Setup

Before (manual setup):

  • 20-step guide
  • 2 hours of work
  • 90% failure rate
  • Support tickets: 50/week

After (automated provisioning):

  • 1-click setup
  • 10 minutes automated
  • 95% success rate
  • Support tickets: 5/week

User feedback:

“I clicked ‘Register Domain’ and 10 minutes later my website was live with HTTPS. Magic.” - Bakery owner

“I’ve set up custom domains on other platforms. This is the first time it actually worked.” - Consultant

Why This Matters for SaaS Products

Most SaaS products make custom domains painful. We learned:

Bad: Give users a guide → hope they figure it out Good: Automate everything → users just click a button

The startup lesson: If a feature requires 20 manual steps, automate it. Users will pay for convenience.

Key Insights

  1. Orchestration is powerful: Chain AWS APIs together for complex workflows
  2. Background jobs are essential: Long-running tasks can’t block users
  3. Status tracking matters: Users need to see progress
  4. Cleanup is critical: Failed provisioning shouldn’t leave orphaned resources

What’s Next

We’re exploring:

  • Faster SSL validation: Use DNS-01 challenge with Route53 API
  • Multi-domain support: Let users have multiple domains per website
  • Domain transfer: Import existing domains to WebZum
  • Subdomain management: Let users create subdomains (blog.business.com)

But the core insight remains: Automation > manual configuration.


Try it yourself: Generate a website with WebZum, click “Get Custom Domain”, register a domain. 10 minutes later, your website is live with HTTPS.

Building a SaaS? Key takeaway: AWS APIs make complex workflows automatable. Don’t make users configure DNS/SSL manually—build an orchestrator.

The future of SaaS isn’t self-service—it’s full automation.

Ready to Build Your Website?

Join hundreds of businesses using WebZum to create professional websites in minutes, not weeks.

Get Started Free
Live in 5 minutesNo credit card required
Home•Free Tools•Blog•Directory•About•Agencies•Partners
FAQ•Privacy•Terms•© 2026 WebZum