Nick Hammond

Software Developer, Cyclist, & Traveler.

Resolving .dev to Localhost With Ansible & Nginx on OS X

In ansible, development

When developing locally it’s nice to be able to have multiple applications running at once and at pre-determined local domains. The /etc/hosts is one of the simplest ways to configure this but you have to modify it each time you’d like to add a new domain for local development. Pow.cx made this incredibly easy for a while but the better approach is to be able to have a similar environment to what you’re running in production. Nginx is really easy to get up and running with and nice to work with so I’ll be using that.

It’s really quite simple to get things going and there are a few posts that highlight how to get up and running locally. I combined the setup from some of those and added in a few other things to get it going for myself.

Before you get started you’ll need ansible and homebrew installed locally. Installing Ansible via Homebrew makes things easier too.

dnsmasq is what we’ll be using to get *.dev configured for local DNS forwarding. For simplicity we’ll just utilize one playbook file to get everything setup. Start working on your playbook file and put the following contents in it:

---
- hosts: all
  tasks:
    - name: Install dnsmasq
      homebrew: name=dnsmasq

dnsmasq is a great utility for setting up custom resolvers and has replaced some of the older DNS libraries on OSX. You can do some pretty useful things with it and very easily, read through the man page for some ideas.

First we need to tell OSX that *.dev should utilize localhost as the nameserver so that when you request the site your machine knows to just look locally. Add this to the playbook in the tasks section:

- name: Add *.dev resolver
  become: yes
  lineinfile:
    state: present
    line: nameserver 127.0.0.1
    create: yes
    dest: /etc/resolver/dev
    owner: root
    group: wheel

Great, now OSX knows to do a local DNS lookup when we hit somedomain.dev. Next we need to tell dnsmasq about it too.

- name: Resolve *.dev to localhost
  lineinfile:
    state: present
    line: address=/.dev/127.0.0.1
    create: yes
    dest: /usr/local/etc/dnsmasq.conf

After we’ve configured dnsmasq we need to notify the service so that it boots up. This would also run whenever it detects a change, the handlers flush and trigger the notification.

On OSX with anything that’s installed via Homebrew it’s easiest to use the brew services command. Typically with Ansible you can use the service module but there isn’t a core module for OS X yet, you still have to follow the post installation steps. Instead of having to follow the post installation steps and needing to know the full launchctl path, we can just use the brew services command, go ahead and tap that:

brew tap homebrew/services

Great, now we can restart anything installed with homebrew with a simple command instead of having to remember the full path. To get an idea of what you can do with the command just run brew services and you’ll get a list of available commands.

For dnsmasq it utilizes root owned LaunchDaemons so we need to run the services command as root so it looks in the correct places. Here’s a handler to restart dnsmasq:

- name: restart dnsmasq
  become: yes
  command: brew services restart dnsmasq

Now that we have a handler to restart dnsmasq, add it to the original task that needs to notify it:

- name: Resolve *.dev to localhost
  lineinfile:
    state: present
    line: address=/.dev/127.0.0.1
    create: yes
    dest: /usr/local/etc/dnsmasq.conf
  notify: restart dnsmasq

Now we can run our simple playbook to automate the setup of *.dev on our local machine, go ahead and run the playbook.

ansible-playbook -i "localhost," playbook.yml -c local -K

After that finishes if we request any .dev domain then our machine will only look locally but unless you have something listening on port 80 it’s not going to be very helpful. What I prefer to do is to have Nginx listening on port 80 and just have a wildcard server block that points to my working root. The server block will only serve up folders that are configured properly. The alternative is to configure a server for each project that you’d like to work on locally.

We can utilize the template module to build our nginx.conf file, at this point we’re just going to have a variable for our workspace root though. Set this variable at the top of your playbook to wherever your workspace root is on your machine.

vars:
  - workspace_root: /Users/nick/src

Create a template named nginx.conf.j2 with the following contents:


# {{ ansible_managed }}

worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;

    client_body_temp_path /tmp/nginx/;

    server {
      listen 80;
      server_name "~^(?<subdomain>.+)\.dev$";

      root {{ workspace_root }}/$subdomain/public;
    }
}

This is just a simple nginx configuration file that’ll take any request to a .dev domain and look for a folder that matches within your workspace root and has a public directory, i.e. lobsters.dev would map to /lobsters/public. If a domain is requested that doesn’t have a corresponding folder Nginx will server up a simple 404 and that’s it. If you have a project in your workspace folder without a public directory then Nginx will serve up a 403.

We haven’t mentioned installing Nginx yet so let’s go ahead and do that. Modify the first task that utilizes homebrew to install both nginx and dnsmasq.


- name: Install homebrew dependencies
  homebrew: name={{ item }} state=present
  with_items:
    - dnsmasq
    - nginx

While we’re at it we should also add a handler for nginx so that when we write our template we can fire the handler to restart Nginx.

handlers:
  - name: restart nginx
    become: yes
    command: brew services restart nginx
  • become is necessary here if you want to utilize port 80.

Great, now we can copy over our template and tell it to restart Nginx after it’s finished:

- name: Add nginx.conf for .dev
  template:
    src: nginx.conf.j2
    dest: /usr/local/etc/nginx/nginx.conf
  notify: restart nginx

Go ahead and run your playbook again to get nginx installed and running recognizing the new configuration.

ansible-playbook -i "localhost," playbook.yml -c local -K

To test things out go ahead and create a sample project in your workspace root. My workspace root is in /Users/nick/src as an example:

mkdir -p /Users/nick/src/sample/public && echo 'IT WORKS!' > /Users/nick/src/sample/public/index.html

Visit sample.dev in your browser or curl it and you should get that HTML page served back to you.

Here’s the full playbook:


---
- hosts: all
  vars:
    - workspace_root: /Users/nick/src
  handlers:
    - name: restart nginx
      become: yes
      command: brew services restart nginx
    - name: restart dnsmasq
      become: yes
      command: brew services restart dnsmasq
  tasks:
    - name: Install homebrew dependencies
      homebrew: name={{ item }} state=present
      with_items:
        - dnsmasq
        - nginx
    - name: Add *.dev resolver
      become: yes
      lineinfile:
        state: present
        line: nameserver 127.0.0.1
        create: yes
        dest: /etc/resolver/dev
        owner: root
        group: wheel
    - name: Resolve *.dev to localhost
      lineinfile:
        state: present
        line: address=/.dev/127.0.0.1
        create: yes
        dest: /usr/local/etc/dnsmasq.conf
      notify: restart dnsmasq
    - name: Add nginx.conf for .dev
      template:
        src: nginx.conf.j2
        dest: /usr/local/etc/nginx/nginx.conf
      notify: restart nginx

P.S. We should keep in touch, subscribe below.