How to add a custom domain feature to your SaaS with Caddy

How to add a custom domain feature to your SaaS with Caddy
This guide will detail how to offer a custom domain feature to your SaaS product using Caddy. It will allow your customers to use their own mydomain.com when using your product, instead of the generic mydomain.yourproduct.com. This article is inspired by this article from Jack Ellis, but simply provides more details.
I will be explaining how to setup Caddy, on an AWS ec2 instance using a DynamoDB database to store the SSL certificates created.

Creating the EC2 instance

Before configuring Caddy for custom domain support, create and securely store an EC2 keypair:
  1. Access AWS EC2 Dashboard: Visit AWS EC2 Dashboard - eu-west-3 Region and navigate to 'Key Pairs' in the 'Network & Security' section.
  1. Generate Keypair: Click 'Create Key Pair', name it appropriately, and download the generated .pem file.
  1. Secure Storage: Save the .pem file in a secure location, such as a password manager or encrypted storage.
  1. Set Permissions (Unix-based systems): Use chmod 400 your-key-name.pem to restrict access to the file.
  1. Backup and Monitor: Keep a backup in a separate secure location and regularly audit keypair usage.
notion image
After creating your EC2 keypair, the next step is to set up an EC2 instance and an Elastic IP:
  1. Launch EC2 Instance:
      • Select the latest version of Ubuntu.
      • Choose t3.small for the instance size, sufficient for a proxy server.
      • Use the previously created keypair for secure access.
      • In network settings, enable HTTP and HTTPS traffic.
  1. Create and Associate Elastic IP:
      • After launching the instance, create an Elastic IP. This ensures a static IP address for your instance, preventing changes upon reboots.
      • Associate this Elastic IP with your new instance.
  1. SSH Connection:
      • Connect to your instance via SSH. Click the 'Connect' button on your EC2 instance page to get the specific SSH command.
      • Use the command format: ssh -i "yourkeyname.pem" ubuntu@yourserveraddress.com for secure access.

Installing Caddy on Your EC2 Server

Here's how to install Caddy with xcaddy on your EC2 server, specifically to integrate the DynamoDB plugin:
  1. Switch to Root User:
      • Enter sudo su to gain root access.
  1. Update Package Lists:
      • Run apt-get update.
  1. Install Necessary Packages:
      • Execute sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https.
  1. Add xcaddy's GPG Key:
      • Run curl -1sLf 'https://dl.cloudsmith.io/public/caddy/xcaddy/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-xcaddy-archive-keyring.gpg.
  1. Update System Packages:
      • Perform another update with sudo apt update.
  1. Install Go 1.20:
      • Create a directory with mkdir goinstall && cd goinstall.
      • Download Go with wget https://go.dev/dl/go1.20.linux-amd64.tar.gz.
      • Extract it using tar -xvf go1.20.linux-amd64.tar.gz.
      • Move Go to the system directory: mv go /usr/local.
      • Set environment variables:
        • export GOROOT=/usr/local/go
        • export PATH=$GOPATH/bin:$GOROOT/bin:$PATH.
      • Verify the installation with go version.
  1. Install xcaddy:
      • Use sudo apt install xcaddy.
  1. Build Caddy with DynamoDB Support:
      • Build Caddy using xcaddy: xcaddy build --with github.com/silinternational/certmagic-storage-dynamodb/v3.
  1. Move Caddy to System Directory:
      • sudo mv caddy /usr/bin/.
  1. Verify Installation:
      • Confirm the DynamoDB plugin installation with caddy build-info | grep "dynamo".
This process ensures Caddy is installed with the necessary DynamoDB support, preparing your server for custom domain configuration.

Setting up DynamoDb

Setting up DynamoDB for managing SSL certificates with Caddy involves a few key steps:
  1. Create DynamoDB Table:
      • Open DynamoDB in the AWS Console.
      • Create a new table named caddy_ssl_certificates with a primary key PrimaryKey.
      • Opt for on-demand capacity (no free tier but auto-scales).
  1. Enable Point-in-Time Recovery:
      • After table creation, go to the 'Backups' tab and enable 'Point-in-Time Recovery'.
  1. Create IAM User for Table Access:
      • The next step involves creating an IAM user specifically for accessing this DynamoDB table.

Creating IAM Policy for DynamoDB Table Access

  1. Create IAM Policy:
      • Choose DynamoDB and select 'All Actions' or specify actions if you're more experienced.
      • Add the table ARN from the DynamoDB page to specify access to the caddy_ssl_certificates table.
      • Name the policy AccessToCaddySSLDynamoTable.

Creating IAM Role for EC2 Instance Access

  1. Create IAM Role:
      • Select AWS service, choose EC2 for the use case.
      • Attach the AccessToCaddySSLDynamoTable policy.
  1. Assign Role to EC2 Instance:
      • In the EC2 dashboard, go to your instance's 'Actions' > 'Security' > 'Modify IAM Role'.
      • Select the Ec2AccessToCaddyDynamoTable role.
This setup ensures your EC2 instance can securely access the DynamoDB table, which is critical for managing SSL certificates with Caddy.

Implementing Domain Authorization in Caddy

When integrating Caddy into your SaaS infrastructure, it's crucial to ensure that SSL certificates are generated only for your customers' domains, preventing unauthorized users from exploiting your services. To achieve this, you'll need to set up an API endpoint that Caddy will consult to verify if a domain is authorized. Below, I'll detail the process using a simple implementation inspired by an example from Laravel News.

Creating the API Endpoint

  1. Define the Route in Laravel:
      • Edit your routes/web.php file to define a new route that points to a specific controller method. For instance:
        • Route::get('caddy-check-8q5efb6e59', 'CaddyController@check');
      • This creates an endpoint that Caddy can query, passing the domain as a query parameter.
  1. Implement the CaddyController:
      • Create a controller, app\Http\Controllers\CaddyController.php, to handle the logic:
        • <?php
          
          namespace App\Http\Controllers;
          
          use Illuminate\Http\Request;
          use Illuminate\Support\Facades\App;
          
          class CaddyController extends Controller
          {
              protected static $authorizedDomains = [
                  'portal.my-customers-website.com' => true
              ];
          
              public function check(Request $request)
              {
                  if (isset(self::$authorizedDomains[$request->query('domain')])) {
                      return response('Domain Authorized');
                  }
          
                  // Abort if there's no 200 response returned above
                  abort(503);
              }
          }
          
          
      • This controller maintains an array of authorized domains. When Caddy makes a request to this endpoint, the check function determines if the domain is in the list of authorized domains. If it is, the function returns a 'Domain Authorized' response; otherwise, it aborts the request with a 503 error.

How It Works

  • Caddy's Verification Process:
    • When Caddy receives a request to generate an SSL certificate for a domain, it first calls this API endpoint.
    • It passes the domain in question as a query parameter to the endpoint.
    • The API checks against the list of authorized domains. If the domain is found, it confirms authorization by returning a 200 HTTP response. If not, it denies the request.
  • Security Benefits:
    • This method ensures that SSL certificates are only generated for domains belonging to your customers.
    • It prevents unauthorized users from pointing their domains to your infrastructure and receiving SSL certificates.
  • Flexibility and Control:
    • The list of authorized domains can be dynamically managed within your application, allowing for easy updates and administration.
By integrating this API endpoint with Caddy, you effectively control which domains can utilize your SSL certificate generation service, enhancing the security and integrity of your SaaS offering.

Configuring Caddy with the Caddyfile

To configure Caddy on your server, you'll need to edit the Caddyfile, which is Caddy's configuration file. Here's how to do it:
  1. Open Caddyfile for Editing:
      • Use nano /etc/caddy/Caddyfile to open the Caddyfile in the nano text editor.
  1. Caddyfile Configuration:
      • Here's a template for the Caddyfile. You'll need to replace the placeholders (marked as TODO) with your specific details:
        • jsonCopy code
          {
              on_demand_tls {
                  # TODO: change this to your endpoint check URL
                  ask https://yourdomain.com/caddy/ask-certificate/your-endpoint
              }
          
              storage dynamodb caddy_ssl_certificates {
                  # TODO: change this to your AWS region
                  aws_region your-aws-region
              }
          }
          
          :80 {
              respond /health "I'm healthy!"
          }
          
          :443 {
              # TODO: change with your email
              tls your-email@example.com {
                  on_demand
              }
          
              # TODO: change with your SaaS domain
              reverse_proxy https://your-saas-domain.com {
                  header_up Host your-saas-domain.com
                  header_up User-Custom-Domain {host}
                  header_up X-Forwarded-Port {server_port}
                  header_up X-Real-IP {remote}
          
                  health_timeout 5s
              }
          }
          
          
  1. List of Things to Change:
      • Endpoint Check URL: Replace with the URL of your domain authorization endpoint.
      • AWS Region: Set to the region where your DynamoDB table is hosted.
      • Your Email: Replace with your email address for TLS certificate notifications.
      • Your SaaS Domain: Replace with the domain name of your SaaS application.
  1. Saving the Configuration:
      • After making the changes, save the file and exit nano (Ctrl + X, then Y to confirm, and Enter).
This Caddyfile configures Caddy to handle on-demand TLS certificate generation, reverse proxy to your SaaS, and integrate with DynamoDB for certificate storage.

Creating a Daemon for Caddy and Starting the Service

To ensure Caddy runs continuously and restarts automatically in case of a crash, you'll need to set it up as a daemon. Here's a brief guide, adapted from the instructions on naiveproxy's GitHub page.
  1. Create Systemd Service File:
      • Start by creating an empty service file with sudo touch caddy.service.
  1. Edit the Service File:
      • Open the file with nano caddy.service.
      • Paste the following configuration:
        • jsonCopy code
          [Unit]
          Description=Caddy
          Documentation=https://caddyserver.com/docs/
          After=network.target
          
          [Service]
          User=caddy
          Group=caddy
          ExecStart=caddy run —environ --config=/etc/caddy/Caddyfile
          ExecReload=caddy reload --config=/etc/caddy/Caddyfile
          TimeoutStopSec=5s
          LimitNOFILE=1048576
          LimitNPROC=512
          PrivateTmp=true
          ProtectSystem=full
          AmbientCapabilities=CAP_NET_BIND_SERVICE
          EnvironmentFile=/etc/environment
          
          [Install]
          WantedBy=multi-user.target
          
          
      • This sets up Caddy to run as a service with the necessary configurations and security limitations.
  1. Move the Service File to Systemd:
      • Use mv caddy.service /etc/systemd/system/ to move your service file to the correct directory.
  1. Start the Caddy Service:
      • Reload the systemd manager configuration with sudo systemctl daemon-reload.
      • Enable Caddy to start on boot using sudo systemctl enable caddy.
      • Start the Caddy service with sudo systemctl start caddy.
  1. Monitoring and Managing the Service:
      • Check Caddy's status: systemctl status caddy.
      • Reload Caddy configuration (after changes): systemctl reload caddy.
This setup ensures that Caddy is properly managed as a system service, providing stability and reliability for your SaaS infrastructure.

Pointing Your Domain to the Caddy Server

To finalize the custom domain setup for your SaaS product using Caddy, you need to point your main domain to the Caddy server's Elastic IP. Here's how to do it:
  1. Update DNS Settings:
      • Access the DNS management interface of your domain registrar.
      • Create an A record pointing to the Elastic IP of the EC2 instance running Caddy. For example:
        • Name: domains.yoursaas.com
        • Type: A
        • Value: [Elastic IP of your EC2 instance]
  1. Instructing Your Users:
      • Inform your users that they can set up a CNAME record from their domain to domains.yoursaas.com. This enables them to use custom domains with your SaaS.
  1. Automatic SSL Certificate Generation:
      • With Caddy configured, SSL certificates will automatically be generated for your customers' custom domains, ensuring a secure and professional experience.
This step completes the process, allowing your users to seamlessly integrate their custom domains with your SaaS while leveraging the automated SSL management provided by Caddy.
That’s it! Your SaaS now has a custom domain feature!

Scaling this architecture

While this custom domain setup with Caddy and DynamoDB has proven effective, it's important to note that it doesn’t scale automatically. This means that as your user base grows, manual intervention may be required to ensure continued smooth operation. Despite this, I've been successfully running this system for over a year for a SaaS platform with more than 100,000 users, and it has remained stable and reliable without any significant issues.
@dragocrnjac, the owner of a SaaS that does all of the custom domain heavy work for you, sent more precise instructions on how to scale this on AWS:
1. Run multiple ec2 instances behind a Network Load Balancer. 2. Run Caddy in a container inside ECS for easy scaling. So in case of a spike in traffic your service stays online, and when there's not a lot of traffic it scales back to 2-3 instances so you're not spending too much money. 3. For extra points and the biggest services, run the ECS service in multiple regions and run them behind AWS Global Accelerator. AWS GL will route traffic from end-users to the closest region where your service runs.
 
Hope you enjoyed reading this article, and hopefully that it helped you! 👋
Julien Nahum

Written by

Julien Nahum

Welcome to my blog where I share my learning bootstrapping online companies!