• Reading time ~ 2 min
  • 25.04.2023

Last Updated on April 18, 2023

Securing your Laravel application with an SSL certificate is crucial in ensuring traffic from and to your application is always encrypted. Some SSL Certificates are free while others have to be purchased and are provided by Certificate authorities such as letsencrypt, and Cloudflare among others.

cloudways

Manually generating these certificates can become a tedious task especially if you have no idea where to start.

Certbot comes to the rescue by providing a free and automated way of generating and renewing these certificates.

It does require manually generating the certificates through Certbot. You can read more on how to add SSL certificates once you have deployed your application to production.

At times we might want to generate these certificates at scale, especially in a multi-tenant application. A good example is Slack. Slack provides each team with a unique subdomain such as team.slack.com.

Using the same analogy, we might also provide users with free subdomains as soon as they are onboarded to our multi-tenant app or as soon as they provide their own custom domains. We will also have to generate SSL certificates for these domain names.

We need to find a way on how we can generate SSL certificates at scale. There are multiple ways of solving this problem such as using the Caddy server. Caddy server provisions free SSL certificates for each host making it an easy tech to add to our stack.

The other option is to use Certbot and generate these certificates if we are using Apache or Nginx. In this tutorial, I will demonstrate how to automate this task for an Nginx-based approach.

This is a continuation of this article where we created a simple URL Shortener service and added the feature of allowing users to add their custom domains.

We will generate SSL certificates as soon as users have added their custom domain names to the system and updated their DNS records to point to our URL Shortener.

Refactoring the code

In the previous part of this series, we were storing domain names in the user’s table. While this is good for a simple URL shortener, most of the time a customer might have multiple domain names. Therefore, we will need a way of handling multiple domains for the same user.

We will create a new migration file that will drop the domain column from the users table

php artisan make:migration drop_domain_column_from_users_table --table=users
dropColumn('domain');
        });
    }
    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('domain')->nullable()->after('email');
        });
    }
};

We will then create a new Domain model that will contain all domain names.

php artisan make:model Domains -m
belongsTo(User::class, 'user_id');
    }
}

Domains Model

id();
            $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
            $table->string('domain');
            $table->boolean('status')->default(0);
            $table->timestamps();
        });
    }
    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('domains');
    }
};

Domains Migration File

A user will now be able to add multiple domains to the system.

Let’s now figure out the DNS side of things.

Working with DNS

Domain name system is a service that helps transform human-readable domain names such as iankumu.com into IP addresses

For our URL shortener, we were allowing users to add their custom domains. This means that we need to provide them with some DNS records that they will add to their DNS providers to complete the mapping to our shortener application

There are two records that we might use: A records or Cname records. A record maps a domain name to an IP address while a Cname record maps one domain name to another.

You can read this guide to understand their differences and which one to choose. Depending on your needs you might choose one or the other.

For my case, I will choose Cname records. I will ask users to add a Cname record pointing to short.app.com

So in my DNS records at Namecheap, I will add this record to the custom domain I want to use in the URL shortener.

Type Host Value TTL
CNAME Record go short.app.com Automatic
Namecheap DNS Records

This will map the domain go.example.com to short.app.com.

With that, my custom domain is ready to be used.

Installing certbot

In the previous part of this tutorial, we installed Nginx onto our server. We now want to install Certbot.

We can follow this tutorial to install Certbot on Nginx.

Generating SSL certificates using laravel and certbot.

Now that the DNS level is set and Certbot is installed, we can now generate SSL certificates for users.

Before we generate any SSL certificates, we first need to verify that the DNS records have propagated correctly. We will use the dns_get_record helper function to get the DNS records of a domain name.

//App/Utils/DomainHelper.php
isfound = true;
                    break;
                } else {
                    $this->isfound = false;
                }
            }
        } else {
            $this->isfound = false;
        }
        return $this->isfound;
    }
}

DNS records can take up to 48 hours to propagate, and thus we need to have a way of checking if the DNS records have propagated.

You can schedule a job to perform these checks. In my case, I will just skip this part. As soon as the records have propagated, we will add the domain to a queue for SSL generation.

Generating the SSL certificates

The next step is to generate the SSL certificates. We will use the new Process facade to work with Certbot. This will allow us to run external processes from our Laravel application.

We will create a new config in the config/services.php file that will allow us to change the Certbot environment based on our needs.

//config/services.php
 [
        'test' => env('CERTBOT_TEST', true)
    ]
...

This configuration will help us determine which Certbot command to run.

//App/Utils/SSLManager.php
certbot_command($domain, $email);
        $certbot = Process::path(base_path())->start($command);
        $process = $certbot->wait();
        if ($process->successful()) {
            return $process->output();
        }

    }
    public function certbot_command($domain, $email): string
    {
        if (config('services.certbot.test') == true) {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email --test-cert";
        } else {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email";
        }
        return $command;
    }

We are using the certonly flag to instruct certbot to only generate an SSL certificate but not update the Nginx config file. This is because we want to prepare a custom Nginx config file for all domains in our system.

We are also using a –test-cert flag to use a staging environment provided by let’s encrypt. This will help increase the certificate per domain limit to 30,000 per week and provide us with test SSL Certificates that we can use to test our logic.

In a production environment, a single domain can only request 5 certificates per week.

Let’s now prepare a simple config file that will proxy requests to our URL shortener.

//App/Utils/SSLManager.php

Our application will prefill the $host and $domain variables with the correct parameters for each domain name

We will create these config files in the sites-available folder for Nginx.

The configurations will be created dynamically with our application.

//App/Utils/SSLManager
certbot_command($domain, $email);
        $certbot = Process::path(base_path())->start($command);
        $process = $certbot->wait();

        if ($process->successful()) {
            $file_path = "/etc/nginx/sites-available/$domain";
            if (File::exists($file_path)) {
                $nginx = File::put($file_path, $this->nginx_config($domain));
            } else {
                touch($file_path);
                $nginx = File::put($file_path, $this->nginx_config($domain));
            }
            if ($nginx) {
                $symlink = $this->execute($this->symlink($domain));
                if ($symlink->successful()) {
                    $finally = $this->execute('sudo nginx -t && sudo systemctl reload nginx');
                    if ($finally->successful()) {
                        return true;
                    } else {
                        return false;
                    }
                }
            }
        } else {
            return false;
        }
    }
    public function certbot_command($domain, $email): string
    {
        if (config('services.certbot.test') == true) {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email --test-cert";
        } else {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email";
        }
        return $command;
    }
    public function nginx_config($domain): string
    {
        $host = "short.app.com";
        $content = "
        server {
            if (\$host = $domain) {
               return 301 https://\$host\$request_uri;
            }
            listen 80;
            listen [::]:80;
            server_name $domain;
        }
        server {
            listen 443 ssl http2;
            listen [::]:443 ssl http2;
            server_name $domain;
            return 301 https://$host\$request_uri;
            resolver 8.8.8.8;
            location / {
                include proxy_params;
                proxy_pass https://$host\$request_uri;
                proxy_set_header Host $domain;
                proxy_set_header X-Forwarded-Host \$http_host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto \$scheme;
                proxy_redirect off;
                proxy_http_version 1.1;
                proxy_set_header Upgrade \$http_upgrade;
            }
            ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
            ssl_trusted_certificate /etc/letsencrypt/live/$domain/chain.pem;
            include /etc/letsencrypt/options-ssl-nginx.conf;
            ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
        }";
        return $content;
    }
    private function symlink($domain): string
    {
        return "sudo ln -s /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/";
    }
    private function execute($command)
    {
        return Process::path(base_path())->run($command);
    }

We will now create a job that will handle the generation of SSL Certificates.

php artisan make:job GenerateSSLJob
//App/Jobs/GenerateSSLJob
domain = $domain;
        $this->email = $email;
    }
    /**
     * Execute the job.
     */
    public function handle(): void
    {
        (new SSLManager())->generateSSL($this->domain, $this->email);
    }
}

With that, our SSL certificate generator is ready.

We can now use the custom domain in our URL shortener.

Deleting the SSL Certificates

Since we are creating SSL certificates at scale, we need a way of deleting them as soon as a user has left our platform.

We can create another method that will handle this task for us.

//App/Utils/SSLManager.php
getUserDomains($user);
        if (!empty($domains)) {
            foreach ($domains as $domain) {
                $nginx_path = "/etc/nginx/sites-available/$domain";
                $this->deleteConfig($nginx_path);
                $this->execute("sudo rm -rf /etc/nginx/sites-enabled/$domain");
                $this->execute("sudo certbot delete --cert-name $domain --non-interactive");
            }
            $finally = $this->execute('sudo nginx -t && sudo systemctl reload nginx');
            if ($finally->successful()) {
                return true;
            } else {
                return false;
            }
        }
    }
    public function getUserDomains(User $user)
    {
        return Domains::where('user_id', $user->id)->pluck('domain')->toArray();
    }
    public function deleteConfig($file_path)
    {
        if (File::exists($file_path)) {
            return File::delete($file_path);
        } else {
            return false;
        }
    }
...

You might also want to revoke certificates at this step so you might also add the logic here.

Refining our code

We can add some helper functions that will help make our application even better.

Validate domain names

Since we are allowing users to add custom domains, we may have cases where users “accidentally” add invalid domain names. Therefore, we need a way of ensuring that only valid domain names will be eligible for SSL generation.

Let’s add another method that will validate a domain name

//App/Utils/DomainHelper.php

We are using a simple regex that I “borrowed” from chat gpt🙂 to check the domain and return if it is valid or not.

Automate SSL renewals

Letsencrypt SSL certificates expire after 90 days. Luckily Certbort can renew SSL certificates for us.

Let’s add the method that will renew domain names for us

//App/Utils/SSLManager.php
execute($command);
    }
...

We will need a way of knowing when to renew these SSL certificates. Let’s add a new column on the domain table that will contain the renewal date.

php artisan make:migration add_renewal_column_to_domains_table --table=domains
dateTime('renewal_date')->nullable()->after('status');
        });
    }
    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('domains', function (Blueprint $table) {
            $table->dropColumn('renewal_date');
        });
    }
};

We will then prepare our job to renew these domains

php artisan make:job RenewSSLJob
domains = $domains;
    }
    /**
     * Execute the job.
     */
    public function handle(): void
    {
        foreach ($this->domains as $domain) {
            (new SSLManager())->renewSSL($domain);
        }
    }
}

Finally, we will schedule the command to run daily. We will get all domains that have expiration due in a month and renew them.

We want to renew them 30 days before they expire to prevent having expired SSL certificates on our server.

//App/Console/Kernel.php
command('inspire')->hourly();
        $renewalDate = Carbon::now()->addDays(30)->toDateTimeString();
        $domains = DB::table('domains')
            ->where('renewal_date', '<=', $renewalDate)
            ->pluck('domain')
            ->toArray();
        if (!empty($domains)) {
             $schedule->job(new RenewSSLJob($domains))->daily();
        }
    }
    /**
     * Register the commands for the application.
     */
    protected function commands(): void
    {
        $this->load(__DIR__ . '/Commands');
        require base_path('routes/console.php');
    }
}

The final helper class should resemble the one below

//App/Utils/SSLManager.php
certbot_command($domain, $email);
        $certbot = Process::path(base_path())->start($command);
        $process = $certbot->wait();

        if ($process->successful()) {
            $file_path = "/etc/nginx/sites-available/$domain";
            if (File::exists($file_path)) {
                $nginx = File::put($file_path, $this->nginx_config($domain));
            } else {
                touch($file_path);
                $nginx = File::put($file_path, $this->nginx_config($domain));
            }
            if ($nginx) {
                $symlink = $this->execute($this->symlink($domain));
                if ($symlink->successful()) {
                    $finally = $this->execute('sudo nginx -t && sudo systemctl reload nginx');
                    if ($finally->successful()) {
                        DB::table('domains')->where('domain', '=', $domain)->update([
                            'status' => 1,
                            'renewal_date' => now()->toDateTimeString()
                        ]);
                        return true;
                    } else {
                        return false;
                    }
                }
            }
        } else {
            return false;
        }
    }
    public function certbot_command($domain, $email): string
    {
        if (config('services.certbot.test') == true) {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email --test-cert";
        } else {
            $command = "sudo certbot certonly --nginx --agree-tos --no-eff-email -d $domain --email $email";
        }
        return $command;
    }
    public function nginx_config($domain): string
    {
        $host = "short.app.com";
        $content = "
        server {
            if (\$host = $domain) {
               return 301 https://\$host\$request_uri;
            }
            listen 80;
            listen [::]:80;
            server_name $domain;
        }
        server {
            listen 443 ssl http2;
            listen [::]:443 ssl http2;
            server_name $domain;
            return 301 https://$host\$request_uri;
            resolver 8.8.8.8;
            location / {
                include proxy_params;
                proxy_pass https://$host\$request_uri;
                proxy_set_header Host $domain;
                proxy_set_header X-Forwarded-Host \$http_host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto \$scheme;
                proxy_redirect off;
                proxy_http_version 1.1;
                proxy_set_header Upgrade \$http_upgrade;
            }
            ssl_certificate /etc/letsencrypt/live/$domain/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/$domain/privkey.pem;
            ssl_trusted_certificate /etc/letsencrypt/live/$domain/chain.pem;
            include /etc/letsencrypt/options-ssl-nginx.conf;
            ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
        }";
        return $content;
    }
    private function symlink($domain): string
    {
        return "sudo ln -s /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/";
    }
    private function execute($command)
    {
        return Process::path(base_path())->run($command);
    }
    public function renewSSL($domain)
    {
        $command = "sudo certbot certonly --force-renew -d $domain";
        return $this->execute($command);
    }
    public function deleteSSL(User $user)
    {
        $domains = $this->getUserDomains($user);
        if (!empty($domains)) {
            foreach ($domains as $domain) {
                $nginx_path = "/etc/nginx/sites-available/$domain";
                $this->deleteConfig($nginx_path);
                $this->execute("sudo rm -rf /etc/nginx/sites-enabled/$domain");
                $this->execute("sudo certbot delete --cert-name $domain --non-interactive");
            }
            $finally = $this->execute('sudo nginx -t && sudo systemctl reload nginx');
            if ($finally->successful()) {
                return true;
            } else {
                return false;
            }
        }
    }
    public function getUserDomains(User $user)
    {
        return Domains::where('user_id', $user->id)->pluck('domain')->toArray();
    }
    public function deleteConfig($file_path)
    {
        if (File::exists($file_path)) {
            return File::delete($file_path);
        } else {
            return false;
        }
    }
}

Final SSLManager Class

Tying it all up

Let’s piece everything together through a controller

php artisan make:controller DomainController
input('domain');
        $email = auth()->user()->email;
        if ((new DomainHelper())->is_valid_domain_name($domain_name)) {
            $domain = Domains::create([
                'user_id' => auth()->id(),
                'domain' => $domain_name,
                'status' => 0
            ]);
            if ((new DomainHelper())->verifycname($domain_name)) {
                $job = (new GenerateSSLJob($domain_name, $email));
                dispatch($job);
            }
            return $domain;
        } else {
            return response()->json([
                'message' => $domain_name . 'is not a valid domain name. Ensure your domain name is valid',
            ], 406);
        }
    }
}

Conclusion

And that’s it. We have automated the process of generating and renewing SSL certificates using laravel.

I hope this article was insightful and showed you how to automate SSL generation and renewals using laravel.

You can tweak the logic to suit your needs. For me, I was using a simple example of a URL shortener and this is how I achieved SSL generation automation.

If you have any questions, feel free to ask them in the comment section.

Thank you for reading.

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

ABOUT

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

About author CrazyBoy49z
WORK EXPERIENCE
Contact
Ukraine, Lutsk
+380979856297