Files for this project are located https://github.com/branhamt/proxy-local-dev.

It’s pretty handy to setup a domain on your local machine that can point to a local server/port combo for local development. Instead of typing localhost:8080 in your address bar, you can type somesite.lcl, or some other domain name you’ve chosen.

This is really handy when you don’t want to have to remember port numbers for all the things running on your machine. Since it’s a multi-part process, it can be a bit of a chore to set it up. That makes it perfect for automation!

In this article, I’ll show how you can:

  • edit /etc/hosts
  • install and run Caddy webserver on Debian-flavored Linux
  • configure Caddy as a reverse proxy
  • run sites with or without SSL
  • and do it all in an Ansible playbook

This setup assumes that there is another server (like a dev server from your stack) serving up a website on a port on your machine. Of course, you can run as many servers and serve as many sites locally as you want.

There is a little streamlining that can be done to the Ansible role file, but I won’t update it until it starts feeling more clunky and ungrokkable.

Set Up /etc/hosts

/etc/hosts lets you map domain names to IP addresses on your machine. This is useful for blocking websites, or redirecting them to other sites. We’re going to map our sites in /etc/hosts like this:

127.0.0.1     somesite1.lcl
127.0.0.1     somesite2.lcl
127.0.0.1     somesite3.lcl

Since /etc/hosts doesn’t handle ports, we don’t include them here. All of these domains will be “forwarded” to our local webserver, and that webserver will decide where to direct a request for each domain, based on the port.

Set Up Caddy Webserver

You could use nginx, Apache, or any other webserver, but I’m going to use Caddy for it’s easy setup.

Caddy is really just proxying to a development server, for my use cases, such as:

  • the Django webserver
  • Node server
  • Hugo webserver

I’m using a Caddyfile, which has very simple setup for a reverse proxy. For a reverse proxy to a site on your machine, specify the domain and port, like this:

somesite1.lcl {
  reverse_proxy :8080
  tls internal

Once your /etc/hosts is set up, this will cause your browser to load https://somesite1.lcl, served from your machine on port 8080, once you type it in your address bar.

Caddy uses it’s own SSL set up on your machine, so we need to specify this with tls internal. You’ll probably need to allow your browser to connect since it will inherently not trust Caddy’s local SSL setup.

Caddy with no SSL

Note that Caddy will configure SSL by default, meaning that your local setup will be served from an address like https://somesite.lcl. This could cause problems depending on what you’re serving, but there is an alternative: no SSL.

In Caddy, you can specify no ssl with this config:

http://somesite2.lcl {
  reverse_proxy :8081

This site will be served on port 8081 from your machine at the address http://somesite2.lcl.

Automating with Ansible

I wanted to be able to automate the above process with one file edit and one command. Let’s start with variables:

Variables

caddy_service_running: true
manage_dns_reverse_proxies: true
dns_and_reverse_proxies:
- { domain: "somesite1.lcl", present: true, caddy_served: true, port: 1313, ssl: true }
- { domain: "somesite2.lcl", present: true, caddy_served: false, port: 8081, ssl: false }
- { domain: "somesite3.lcl", present: true, caddy_served: true, port: 8082, ssl: false }

caddy_service_running controls whether the Caddy webserver runs. This makes it easy-ish to turn off, and lives alongside some other settings that turn off other services.

manage_dns_reverse_proxies controls whether entries appear in /etc/hosts and the Caddyfile. If this is false, all entries are removed.

dns_and_reverse_proxies is self-explanatory.

Caddy Installation

We start with installing Caddy. The Caddy installation instructions look like this:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

I translated these instructions into Ansible YAML here:

- name: Install Caddy Server
  block:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true

    - name: Install utilities
      ansible.builtin.apt:
        pkg:
          - ca-certificates
          - gnupg

    - name: Install Caddy signing key
      ansible.builtin.apt_key:
        url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key
        keyring: /usr/share/keyrings/caddy-stable-archive-keyring.gpg
        state: present

    - name: Add Caddy "deb" to sources
      ansible.builtin.blockinfile:
        path: /etc/apt/sources.list.d/caddy-stable.list
        create: true
        block: |
          # Source: Caddy
          # Site: https://github.com/caddyserver/caddy
          # Repository: Caddy / stable
          # Description: Fast, multi-platform web server with automatic HTTPS
          deb [signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main
          deb-src [signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main          

    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true

    - name: Install Caddy
      ansible.builtin.apt:
        pkg:
          - caddy

Control the Caddy Systemd Service

To control whether the service runs, I use this Ansible code:

- name: Turn on/off Caddy services
  vars:
    services:
      - caddy
  block:
    - name: Enable caddy services
      ansible.builtin.systemd:
        name: "{{ item }}"
        enabled: true
      when: caddy_service_running
      loop: "{{ services }}"

    - name: Turn on caddy services
      ansible.builtin.systemd:
        name: "{{ item }}"
        state: started
      when: caddy_service_running
      loop: "{{ services }}"

    - name: Disable caddy services
      ansible.builtin.systemd:
        name: "{{ item }}"
        enabled: false
      when: not caddy_service_running
      loop: "{{ services }}"

    - name: Turn off caddy services
      ansible.builtin.systemd:
        name: "{{ item }}"
        state: stopped
      when: not caddy_service_running
      loop: "{{ services }}"
  tags: update-local-dns-proxy

I’m using the tag update-local-dns-proxy so that I can run a small subset of the role when I make an update to the variables.

Update /etc/hosts and the Caddyfile

This section is a bit more complicated because of the number of variables. We add or remove sites based on the present variable, add SSL if required, and reload Caddy at the end.

- name: Configure reverse proxies
  block:
    - name: Add host to /etc/hosts
      ansible.builtin.lineinfile:
        path: /etc/hosts
        line: "127.0.0.1      {{ item.domain }}"
        state: present
      loop: "{{ dns_and_reverse_proxies }}"

    - name: Add proxy to Caddyfile
      ansible.builtin.blockinfile:
        path: /etc/caddy/Caddyfile
        state: present
        block: |
          {% if not item.ssl %}http://{% endif %}{{ item.domain }} {
            reverse_proxy :{{ item.port }}
            {% if item.ssl %}
            tls internal
            {% endif %}
          }          
        marker: "# {mark} {{ item.domain }}:{{ item.port }} ANSIBLE MANAGED BLOCK"
      when: item.caddy_served
      loop: "{{ dns_and_reverse_proxies }}"
  when: manage_dns_reverse_proxies and item.present
  tags: update-local-dns-proxy

- name: Remove reverse proxies
  block:
    - name: Remove host from /etc/hosts
      ansible.builtin.lineinfile:
        path: /etc/hosts
        line: "127.0.0.1      {{ item.domain }}"
        state: absent
      loop: "{{ dns_and_reverse_proxies }}"

    - name: Remove proxy from Caddyfile
      ansible.builtin.blockinfile:
        path: /etc/caddy/Caddyfile
        state: absent
        block: |
          {% if not item.ssl %}http://{% endif %}{{ item.domain }} {
            reverse_proxy :{{ item.port }}
            {% if item.ssl %}
            tls internal
            {% endif %}
          }          
        marker: "# {mark} {{ item.domain }}:{{ item.port }} ANSIBLE MANAGED BLOCK"
      loop: "{{ dns_and_reverse_proxies }}"
  become: true
  when: not manage_dns_reverse_proxies or not item.present
  tags: update-local-dns-proxy

- name: Reload Caddy
  ansible.builtin.systemd:
    name: caddy
    state: reloaded
  # become: true
  tags: update-local-dns-proxy

Commands

To update what sites are being reverse proxied (or only added to /etc/hosts) I run:

ansible-playbook x270.yml --ask-become-pass --tags=update-local-dns-proxy

This is the playbook for my local machine, but the tag will cause only the tagged tasks above to run.

This setup does require root to run the service and modify files.

Bash Alias

Finally, to make things a little easier, I added a function in my bash aliases:

function proxy-dns () {
    venv ansible; # alias to activate the ansible virtualenv
    cd ~/workstation/ansible || exit;
    ansible-playbook x270.yml --ask-become-pass --tags=update-local-dns-proxy
    cd - || exit;
    deactivate
}

This is pretty self-explanatory. I’m using Ansible in a virtualenv, so that is activated (by an alias) and deactivated at the end. I’m sure I’ll forget this command!

.dev TLD Issues

I thought I might, maybe, have some problems with the .dev TLD, based on articles like this.

Using Firefox, some sites with a .dev domain don’t appear to be loading correctly. I may switch to only using .lcl.