# Custom domains and SSL in Rails development

Custom domains for local development in Rails can be a nice addition to our toolbox.

Trading localhost and some port number for a short and memorable domain name sounds nice, right? How about if we throw some secure connections into the mix?

In this article, we will learn **how to add custom domains and SSL in Rails development**, the different ways to do it and best practices.

Let's start by understanding why these features can be important:
## Why custom local domains and SSL
There's nothing wrong with the traditional Rails approach of starting the server using `rails server` or `bin/dev` and navigating to `localhost:3000`.

But there are some cases where having a custom domain name, even for local development, can be useful:

- **Working with subdomains:** route constraints can help us work with subdomains, but browser session cookies are domain-scoped. This means that `api.localhost:3001` and `localhost:3000` are considered different domains, which means that cookies won't be shared between them in case we need it.
- **Development and production parity:** having parity between development and production is generally desirable. Of course, SSL is just one part of the equation, but it can help us discover issues before our customers do. Things like CORS or secure cookies are better tested with custom and secure local domains.
- **Service workers and PWA features:** because service workers can only be registered over a secure connection, having local HTTPS can be helpful to test them and make sure they work before deploying them to production.
- **Multiple apps at the same time:** sometimes, we need to have many apps running at the same time. This is especially true if we are building an API with Rails and a frontend app using a JS framework. Assigning custom domains or subdomains to these apps can reduce the number of ports to keep in mind.

{{info}}
When we talk about **custom local domains**, we usually refer to "fake” domains that don't correspond to actual extensions or TLDs. Of course, we can use any domain as long as we configure things appropriately but, if we were using real domains, we would be typically tunneling our local environment to the internet using a service like ngrok.
{{/info}}
## Doing things manually
Before jumping into tools that make our life easier, let's get our hands dirty and learn how everything works behind the scenes.

Generally, to define custom local domains with Rails, we need a way to relate requests made to our chosen domain into our Rails application and a way to generate an SSL certificate that secures the connection.

Let's start by exploring custom local domains by editing the hosts file:
### Editing the hosts file

There are many ways to achieve the first; however, the easiest is to edit the `/etc/hosts` file and add the domain in there. 

Let's add the `domainer.test` domain and have it resolve to localhost (`127.0.0.1`).

If we run `cat /etc/hosts` we should see something like this:

```text
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1       localhost
255.255.255.255 broadcasthost
::1             localhost
```

Let's add a definition for our custom domain by running `sudo vim /etc/hosts` and adding the following line at the bottom of the file:

```text
127.0.0.1  domainer.test
```

Now, if we ping our host `ping domainer.test`, we should see something like

```text
PING domainer.test (127.0.0.1): 56 data bytes
```

This means that the requests are going to our desired IP address.

If we have a Rails server running, and we visit `domainer.test:3000` we should see our application running just like we do normally with `localhost:3000`:

![Rails application after accessing the domain with the correct port](https://i.postimg.cc/g217Mp3R/domainer-local-with-ports.webp)

However, we want to access the domain without the need of appending the ports. We can achieve that by setting up a reverse proxy with nginx or Caddy. Let's see both ways of doing it:

{{info}}
The `etc/hosts` is a plain-text file that serves as a basic name resolution mechanism. It matches fully qualified domain names with IP addresses. The resolution order is the `/etc/hosts` file with the highest priority, the `etc/resolver` which is macOS-specific and lastly the system DNS servers. Our custom domain definitions will always take precedence over DNS records, so take that into consideration and proceed with caution.
{{/info}}
### Reverse proxy with nginx
Before anything, make sure you have nginx installed on your local machine. Otherwise, follow these [installation instructions](https://kinsta.com/knowledgebase/install-nginx/) to properly install it on your machine.

Since I'm using macOS with nginx installed via Homebrew, I'll configure the site by adding it to `/opt/homebrew/etc/nginx/servers`:

```nginx
server {
  listen 80;
  server_name domainer.test;

  location / {
    proxy_pass http://localhost:3000;
    proxy_set_header Host $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;
  }
}
```

Then, we run the nginx service using `brew services start nginx`, and when we access `http://domainer.test` we should see our landing page with the “Not secure” browser alert:

![Custom domain with reverse proxy but no SSL](https://i.postimg.cc/g217Mp3R/domainer-local-with-ports.webp)

To solve this issue, let's generate an SSL certificate for our domain using [mkcert](https://github.com/FiloSottile/mkcert), a tool for making locally trusted certificates intended for development.

Run the following command:

```shell
mkdir ~/.ssl && cd ~/.ssl && mkcert domainer.test
```

This will generate a `domainer.test.pem` certificate and a `domainer.test-key.pem` key, storing it in the `.ssl` folder of the home directory, but you can place them elsewhere if you need it.

Now, to add SSL we have to make our nginx server listen on the `443` port instead and redirect HTTP requests to HTTPS:

```nginx
server {
  listen 443 ssl;
  server_name domainer.test;

  ssl_certificate /Users/erozas/.ssl/domainer.test.pem;
  ssl_certificate_key /Users/erozas/.ssl/domainer.test-key.pem;

  location / {
    proxy_pass http://localhost:3000;
    proxy_set_header Host $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;
  }
}

server {
  listen 80;
  server_name domainer.test;
  return 301 https://$host$request_uri;
}
```

Now, we restart nginx using `brew services restart nginx`, navigate to `https://domainer.test` and we should see our landing page without any security alert:

![Local domain using nginx and SSL](https://i.postimg.cc/mkbYQ7Gd/local-domain-with-ssl-nginx.webp)

{{info}}
Enjoying this article? You might enjoy Avo, the ultimate [Rails admin](https://avohq.io/rails-admin) and internal tooling gem for Ruby on Rails that can help you build better Rails apps faster.
{{/info}}

### Reverse proxy with Caddy
Caddy is an application server written in Go that offers automatic SSL certificate management. It's simpler than nginx and can be configured using JSON or a `Caddyfile`.

The first step is to make sure we have Caddy installed. Check the [installation instructions](https://caddyserver.com/docs/install) for your operating system and make sure you have access to the `caddy` command.

To avoid any conflicts with the nginx configuration, we will use the `domainer.dev` domain.

{{warning}}
Please consider that `.dev` is a valid commercial TLD. We're using it here to show that we can use any domain we want as long as we follow the steps. However, to avoid any potential issues, use other TLDs.
{{/warning}}

Let's start by adding it to our `etc/hosts` file:

```text
127.0.0.1 domainer.dev
```

Now, from our Rails app directory, we create a `Caddyfile` and add the following:

```nginx
domainer.dev {
  tls internal
  reverse_proxy localhost:3000
}
```

Then, we can run Caddy by adding it to our `Procfile.dev` file:

```text
web: env RUBY_DEBUG_OPEN=true bin/rails server
js: yarn build --watch
css: bun run build:css --watch
caddy: caddy run
```

Now, we make sure to stop nginx to avoid any mix-up from the previous step and run `bin/dev` and we should be able to access our application using the `domainer.dev` domain:

![Custom domains and SSL in Rails using Caddy](https://i.postimg.cc/P5tqTKQV/custom-domain-with-ssl-in-caddy.webp)

As you can see, the certificate here is issued by **“Caddy Local Authority”** and everything was automatically handled by Caddy without having to do anything beyond the configuration we saw before.

Now that we've done things manually using the hosts file, nginx and Caddy, let's see how to approach this issue with the **puma-dev** library:

{{info}}
If we don't use the `tls internal` configuration, Caddy will try to fetch the certificate from the actual domain by looking at the `.well-known` directory and, unless we control the domain we're trying to use and have a valid certificate there, the process will fail.
{{/info}}
## Using the puma-dev gem
Now that we've learned how to work with custom local domains manually, let's learn about the [puma-dev](https://github.com/puma/puma-dev) gem, a library that runs on top of Puma and gives us HTTPS and the ability to run custom domains like `.test` and `.puma`.

We have to make sure we have the gem installed in our system. As I'm using macOS, I will install it using Homebrew:

```shell
brew install puma/puma/puma-dev
```

Having the `puma` gem as a dependency is also a requirement:

```ruby
gem "puma", ">= 5.0"
```

Now, to install the gem and set it up, we need to run the following command:

```shell
sudo puma-dev -setup
```

This command runs the following:

```shell
* Configuring /etc/resolver to be owned by erozas
* Changing '/etc/resolver/test' to be owned by erozas
```

This produces the following configuration in the `/etc/resolver/test` file:

```text
# Generated by puma-dev
nameserver 127.0.0.1
port 9253
```

Unlike the `/etc/hosts/` which maps specific domains to IP addresses, the `/etc/resolver` files tell the system which nameserver to use for entire TLDs. This is why puma-dev can handle any `.test` domain dynamically. The `/etc/hosts` requires an explicit entry for each domain.

Consider that the first time we run the `puma-dev` command, we will likely encounter a dialog asking for our password. This happens because `puma-dev` generates its CA certification that's stored in `~/Library/Application Support/io.puma.dev/cert.pem`.

This CA is then used to sign individual certificates for each domain, which is why your browsers trust them.

{{warning}}
If you're running **puma-dev** on Linux, there are a couple of extra steps you have to consider to make sure all the features work. The first is to **install and trust the root CA** as a Certificate Authority by adding it to the OS's certificate trust store or directly in the browser. The second is that you might need to install the `dev-tld-resolver` for `test` domains to work. The third is that you have to **manually configure port 80/443 binding** because it's not allowed by default on Linux and lastly, you will have to **manually create a system daemon** to start up puma-dev in the background.
{{/warning}}

Next, we run the following to have `puma-dev` run in the background locally on ports `80` and `443`:

```shell
puma-dev -install
```

Now, after we have everything setup, we can go to our application folder and run the following command:

```shell
puma-dev link
```

Because our application is called `domainer` and located in the directory with the same name, we can now go to `https://domainer.test` and we should get the app running:

![Domain with SSL using puma-dev and the .test TLD](https://i.postimg.cc/05GG9pYQ/domainer-test-puma-dev.png)


Notice that we didn't have to run the `rails server` or the `bin/dev` commands to have our application running. That's because `puma-dev` works in the background.

Puma-dev works by monitoring the `~/.puma-dev` directory for symbolic links. Each of the symlink's names becomes a subdomain under `.test` and the symlink tells puma-dev which application to start when the domain is accessed.

To prove this, let's go to the `~/.puma-dev` directory and manually add a symlink with a random domain name to the AvoCasts app directory, the application we used for the [multistep-forms with Rails](https://avohq.io/blog/multistep-forms-rails) article. 

When we visit that random name we gave to the symlink, we should get the application running without doing anything else:

```shell
ln -s ~/Code/rails/avo/content-apps/wizard_test ~/.puma-dev/avocasts
```

As you can see, the actual application name is `WizardTest` but I'm making a symbolic link inside the `.puma-dev` directory so now, I should be able to access `avocasts.test`:

![Local domain access with the puma-dev gem](https://i.postimg.cc/XqrPmG0b/avocasts-test-puma-dev.webp)

### Things to consider
Because the workflow using `puma-dev` is significantly different from what we're used to with the `rails server` command, there are some things to consider:
#### Background service
When we run `puma-dev -install` command, the service is added to the macOS login items:

![Puma dev in the macOS login items](https://i.postimg.cc/RhL0pQjW/puma-dev-login-items.webp)

This means that it runs in the background and starts every time we restart our computer. If you find that to be an annoyance, you can remove it by going to **System Settings ⇾ General ⇾ Login Items**, searching for `puma`dev and toggling the switch.
#### Certificates
If, for some reason, you didn't accept the dialog to trust the puma-dev root Certificate Authority, you can find it in the **Keychain Access** section of **Utilities.**

In there, we can search for **puma** and we should see something similar to this:

![Puma dev CA in the Keychain Access](https://i.postimg.cc/GpjdChXf/puma-dev-certificate-authority.webp)

Then, after double-clicking the certificate, we should be able to toggle the trust configuration to **“Always Trust”**.

![Trusting puma-dev root Certificate Authority](https://i.postimg.cc/8kx5wc9d/trusting-puma-dev-ca.webp)

After doing this, we should be able to access our local domains using HTTPS.
#### Application logs
If you want to **check the application logs,** you will have to run the following command inside the app's directory:

```shell
tail -f log/development.log
```

Because the gem does everything behind the scenes to run our application, a terminal tab running this command replaces the usual `rails s` tab.

Besides the application logs, the gem itself produces logs that we can output to the console by running:

```shell
tail -f ~/Library/Logs/puma-dev.log
```

Consider that these logs are library related, so they're useful to debug issues with it but not the application itself.
#### Restarting the app
There are a couple of ways to restart our application whenever we need it. 

The first is to run the restart command:

```shell
bin/rails restart
```

Which is the same as running:

```shell
touch tmp/restart.txt
```

The other way to restart is to run:

```shell
puma-dev -stop
```

And then visiting the local domain to restart the process.

Note that this command doesn't stop the service in the traditional way. If we want to do that, we can run the following command on macOS:

```shell
pkill -USR1 puma-dev
```

{{info}}
Errors that occur within the application code will show normally when using `puma-dev`. However, if there are errors in the initialization process, we get a `unexpected exit` error which is not very helpful, but we can access more helpful logs using the commands shown above.
{{/info}}
## Choosing an approach
Now that we've seen both ways of adding custom subdomains to a Rails app, let's see when to use each approach.

Use the manual configuration when:
- You require specific custom or vanity domains, not just `.test`.
- You're working with non-Rails apps.
- You need fine-grained control over the proxy settings.
- You're setting a shared development environment.

On the other hand, it's probably better to use puma-dev when:

- Working primarily with Rails applications.
- You would rather not deal with configuration or customization.
- You switch between multiple projects.

## Summary
Working with custom local domains in Rails can be a nice addition to our tool belt, especially if we add SSL to the mix.

Whether your application uses subdomains, interacts with DNS records or implements service workers, having custom domains and HTTPS locally can be an advantage.

We explored how to add custom domains manually by:

- Editing the hosts file to map our custom domains to `127.0.0.1 (localhost)`.
- Setting up a reverse proxy with nginx and adding SSL certificates using mkcert.
- Using Caddy to achieve the same result with minimal configuration and automatic certificate handling.

Then, we used the **puma-dev** library to achieve the same results in Rails projects, but quicker and without having to configure anything beyond installing the library and running a couple of commands.

The manual approach is a good way to understand how things work and also be able to use any domain we want.

On the other hand, using **puma-dev** is easier, but it has some limitations related to the domains we can use and the ability to freely access our application logs or restart the server as we see fit.

After installing it, to use `puma-dev` with a given project, we just have to run the following command inside our application's directory:

```shell
puma-dev link
```

This generates a symlink inside `~/.puma-dev` to our application directory, and after doing that, `puma-dev` takes over and handles the request.

If we want to restart the application, we can run `bin/rails restart` on the application directory or `puma-dev -stop` and then visit our custom domain.

If you're working with multiple developers, and you want to streamline the process, consider including a script that automates the process when setting the application up.

I hope you enjoyed this article and that it helped you implement custom domains with a secure connection for your project.

Oh, and don't forget that if you edited the hosts file, it can interfere with your navigation. DNS bugs are usually not fun!

Have a good one and happy coding!
