Skip to main content
  1. Posts/

Expose Your Phoenix App via ngrok (and Why Dev Friction Compounds)

·4 mins
Justin Smestad
Author
Justin Smestad
Table of Contents

The fastest way to slow down a team is to let small annoyances pile up in the local dev environment.

Nobody notices one developer copy-pasting an ngrok hostname into a config file. But multiply that by every engineer on the team, several times a day, across weeks, and you’ve got a real drag on iteration speed. Worse, it trains people to accept friction as normal. Once a team stops noticing the small stuff, they stop fixing the medium stuff too.

This post solves a specific problem (dynamically wiring an ngrok hostname into a Phoenix dev server), but the principle is bigger: every manual step in your dev workflow is a candidate for automation, and leaders who treat the dev environment as a product ship faster.

The problem
#

ngrok is the standard tool for exposing a local server to the internet. You need it for mobile testing, webhook development, showing work to a stakeholder on another network, and plenty more.

On ngrok’s free tier, you get a randomly generated hostname every time you start a tunnel. That means you need to copy the new hostname, paste it into your Phoenix config or environment, and restart the server. Every single time.

It’s a small thing. It’s also the kind of small thing that interrupts flow state, and flow state is where the actual work happens.

Update (2024): ngrok now offers free static domains on their free tier, which eliminates this problem entirely for most use cases. If you’re starting fresh, use a static domain and skip the automation below. The rest of this post is still useful if you’re on a plan without static domains, or if you want to see how the automation pattern works for other dynamic-config problems.

Configure Phoenix to use a dynamic host
#

Set up your config/dev.exs to read the hostname from an environment variable:

# config/dev.exs

config :my_app, MyApp.Endpoint,
  http: [port: 4000],
  url: [scheme: "https", host: {:system, "HOST"}, port: "443"],
  debug_errors: true,
  code_reloader: true,
  check_origin: false,
  watchers: [
    node: [
      "node_modules/webpack/bin/webpack.js",
      "--mode",
      "development",
      "--watch-stdin",
      cd: Path.expand("../assets", __DIR__)
    ]
  ]

This tells Phoenix to generate URLs using whatever HOST is set to at boot time, with HTTPS on port 443 (which is what ngrok provides). If you need plain HTTP instead, change the scheme to "http" and the port to "80".

The key detail: {:system, "HOST"} reads the value at runtime, not compile time. That’s what makes this work with a hostname that changes on every ngrok restart.

Auto-populate the hostname with a Makefile
#

ngrok runs a local API at http://127.0.0.1:4040 that exposes tunnel metadata. We can query it to grab the current public hostname and inject it into Phoenix automatically.

This requires curl and jq to be installed (both are common on macOS and Linux).

NGROK_HOST := $$(curl --silent http://127.0.0.1:4040/api/tunnels \
  | jq '.tunnels[0].public_url' \
  | tr -d '"' \
  | awk -F/ '{print $$3}')

.PHONY: serve-ngrok
serve-ngrok: ## Start Phoenix bound to the active ngrok tunnel
	@echo "🌍 Exposing My App @ https://$(NGROK_HOST)"
	env HOST=$(NGROK_HOST) mix phx.server

Here’s what’s happening: curl hits the ngrok API, jq extracts the tunnel URL, and awk strips it down to just the hostname. That hostname gets passed as the HOST environment variable when Phoenix boots. Zero copy-pasting.

If you prefer a bash script over Make, the same curl | jq | awk pipeline works anywhere.

The workflow
#

Two terminals, two commands:

  1. ngrok http 4000 in one shell to start the tunnel
  2. make serve-ngrok in another to boot Phoenix with the correct hostname already wired in

That’s it. The hostname is resolved at boot, Phoenix generates correct URLs, and you never touch a config file.

The bigger point
#

This is a five-minute automation. That’s exactly why it matters.

Most dev-environment friction doesn’t come from one big broken thing. It comes from dozens of tiny annoyances that nobody fixes because each one feels too small to bother with. Copy-paste a hostname here, restart a service there, manually seed a database, toggle a feature flag by hand.

Individually, none of them are worth a meeting. Collectively, they’re the reason your team’s local setup takes a full day for new hires and why half the engineers have slightly different workarounds for the same problems.

If you lead a team, audit the manual steps in your dev workflow the same way you’d audit tech debt in production. The fix is usually simple. The compound effect of not fixing it is not.