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:
- Register domain (Route53)
- Create hosted zone (Route53)
- Request SSL certificate (ACM)
- Validate domain ownership (DNS records)
- Wait for SSL validation (5-30 minutes)
- Create CloudFront distribution
- Configure CloudFront with SSL
- Point domain to CloudFront (DNS records)
- Deploy website files
- 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
- Orchestration is powerful: Chain AWS APIs together for complex workflows
- Background jobs are essential: Long-running tasks can’t block users
- Status tracking matters: Users need to see progress
- 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.