
Five ways ngrokkers 'doglab' gateways for homelabs and side projects
While ngrok already uses ngrok to dogfood ngrok.com
, I can’t forget about its close cousin: When your engineers use your product to power their homelabs.
Honestly, I’m not even sure what to call it. Doglabbing? Homefooding?
Either way, I’ve heard plenty of stories about how ngrokkers use endpoints and Traffic Policy to access their homelabs from everywhere, or securely share services with friends and family, and thought it was about time for a round-robin look at the shapes of their networks, the policies that rule them, and the gateways that wire it all up.
So, let’s hear from each engineer, in their own words, about exactly how they’re using ngrok for their personal homelabs and self-hosted side projects.
Christian (Staff Data Infrastructure Engineer): Self-hosted analytics with umami, secured with OAuth
While I also use ngrok
+ Traffic Policy for my home lab, my recent use case was for actually for my website and blog on a VPS, where I wanted to host umami
as a privacy-focused alternative to Google Analytics via docker
.
To avoid having to run it bare metal or to set up a reverse proxy or similar plumbing (the server already runs nginx
and not much else), I used ngrok
to expose the service and Traffic Policy to secure it—the admin interface should only be accessible to me, where the client-side script needs to be publicly accessible.
I secure all services with OAuth and IP Intelligence rules to filter on conn.client_ip.is_on_blocklist
(and geo location, occasionally).
However, since umami
needs to serve its script client-side, I needed to set up some exclusion rules to the OAuth path to ensure /script.js
can be served (and that it works!) without OAuth. I've had to use this trick for other services that expose external endpoints, but do not completely contain them on one path.
Agent configuration:
version: 3
endpoints:
- name: umami
url: https://example.org
upstream:
url: "http://host.docker.internal:3000"
traffic_policy:
on_http_request:
- name: block spam
expressions:
- "conn.client_ip.is_on_blocklist == true"
actions:
- type: custom-response
config:
content: Unauthorized request
status_code: 403
- name: Add `robots.txt` to deny all bots and crawlers
expressions:
- req.url.contains('/robots.txt')
actions:
- type: custom-response
config:
status_code: 200
content: "User-agent: *\r\nDisallow: /"
headers:
content-type: text/plain
- name: oauth
expressions:
- "!(req.url.path.contains('/_next') || req.url.path.contains('/script.js') || req.url.path.contains('/api/send'))"
actions:
- type: oauth
config:
auth_id: oauth
provider: google
- name: bad email
expressions:
- "!(actions.ngrok.oauth.identity.email in ['myemail@example.org']) && !(req.url.path.contains('/_next') || req.url.path.contains('/script.js') || req.url.path.contains('/api/send'))"
actions:
- type: custom-response
config:
content: Unauthorized
status_code: 400
on_http_response: []
James (Senior Software Engineer): Standardized AuthN and routing for everything
I wanted to be able to have a central policy for controlling auth, header additions, and not need to think about these on a per endpoint basis.
My solution was to create a CloudEndpoint
CRD attached to wildcard domain, use set-vars
to chop off the subdomain and forward-internal
to an internal endpoint. Now I don't need to worry where my internal endpoints live, just what they are named. I have ngrok Kubernetes Operator-managed AgentEndpoints
, ngrok Docker-started endpoints, and internal CloudEndpoints
all with consistent behavior and unified auth.
Gateway config via the ngrok Kubernetes Operator and a CloudEndpoint
CRD:
on_http_request:
- name: oauth - google@ngrok.com
actions:
- type: oauth
config:
auth_cookie_domain: example.com
auth_id: oauth
provider: google
- name: denied
expressions:
- "!(actions.ngrok.oauth.identity.email in ['allowed_email_01@gmail.com',
'allowed_email_02@gmail.com', 'allowed_email_03@gmail.com',
'allowed_email_04@gmail.com'])"
actions:
- type: custom-response
config:
body: Not Authorized
status_code: 403
- name: savesubdomain
actions:
- type: set-vars
config:
vars:
- inbound_subdomain: ${req.host.split('.example.com')[0]}
- name: add auth header
actions:
- config:
headers:
NGROK_AUTH_USER_EMAIL: ${actions.ngrok.oauth.identity.email}
type: add-headers
- name: forward-k8s
actions:
- config:
url: https://${vars.inbound_subdomain}.internal
type: forward-internal
Stacks (Staff Software Engineer): Many services, one standard AuthN path
I’m a long-time Kubernetes user, and I wanted to run Kubernetes in my home lab because I like the ecosystem and I like trying new projects. I wanted a way to hit these projects when I’m not at home, and generally expose these services on the internet, with something simple that wouldn’t require a lot of feeding and wouldn’t force me to open up ports or deal with UPnP or anything like that.
I’m used to setting up K8s in a cloud environment, where I have easy access to a cloud load balancer, or even static IPs for on-prem, but when you’re at the mercy of your ISP, this is a lot trickier. See the MetalLB project for proof of how hard this can be based on your network.
ngrok's Traffic Policy and ability to put OAuth in front of my applications was a big plus. It’s an extra layer of protection to make sure I'm the only one accessing my homelab, and I don’t have to worry about security vulnerabilities in the apps themselves or doxxing my IP because people don’t see where I’m hosting everything.
And it just works wherever—if I unplugged my homelab and went to Cali for an offsite, and had internet access, it would be up and running again.
Ingress
resources for Argo CD’s dashboard and gRCP server:
---
apiVersion: v1
kind: Service
metadata:
name: argocd-server
namespace: argocd
annotations:
k8s.ngrok.com/app-protocols: '{"https": "HTTPS", "grpc": "HTTPS"}'
spec:
type: ClusterIP
ports:
- name: http
port: 80
protocol: TCP
targetPort: 8080
- name: https
port: 443
protocol: TCP
# appProtocol: k8s.ngrok.com/http2 # OR "kubernetes.io/h2c"
targetPort: 8080
- name: grpc
port: 444
protocol: TCP
appProtocol: k8s.ngrok.com/http2
targetPort: 8080
selector:
app.kubernetes.io/name: argocd-server
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: argocd-ingress
namespace: argocd
annotations:
external-dns.alpha.kubernetes.io/hostname: argocd.example.com
external-dns.alpha.kubernetes.io/ttl: 1m
k8s.ngrok.com/traffic-policy: google-oauth
k8s.ngrok.com/mapping-strategy: endpoints-verbose
spec:
ingressClassName: ngrok
rules:
- host: argocd.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: argocd-server
port:
name: https
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: argocd-grpc-ingress
namespace: argocd
annotations:
external-dns.alpha.kubernetes.io/hostname: grpc.argocd.example.com
external-dns.alpha.kubernetes.io/ttl: 1m
k8s.ngrok.com/mapping-strategy: endpoints-verbose
k8s.ngrok.com/traffic-policy: only-trusted-ips
spec:
ingressClassName: ngrok
rules:
- host: grpc.argocd.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: argocd-server
port:
name: grpc
Referenced TrafficPolicy CRDs:
---
kind: NgrokTrafficPolicy
apiVersion: ngrok.k8s.ngrok.com/v1alpha1
metadata:
name: google-oauth
spec:
policy:
on_http_request:
- actions: # Phase 1 : Authenticate
- type: oauth
config:
provider: google
- expressions: # Phase 2 : Deny if not me
- "!(actions.ngrok.oauth.identity.email in ['example@gmail.com'])"
actions:
- type: custom-response
config:
status_code: 403
content: "Forbidden"
---
kind: NgrokTrafficPolicy
apiVersion: ngrok.k8s.ngrok.com/v1alpha1
metadata:
name: only-trusted-ips
spec:
policy:
on_tcp_connect:
- actions:
- type: restrict-ips
config:
enforce: true
allow:
- '203.0.113.0/32' # My IPv4
- '2001:db8:abcd:1234::1/128' # My IPv6
Ryan (Senior Software Engineer): Eyes-only photo sharing with immich and OIDC
For the past few years, I've slowly been de-Googling my life. One of the last Google services in my life was Google Photos, which remained for one reason: sharing. After going on a trip, I want to give my companions access to all the pictures I took, allowing them to pick their favorites and download them. I also want an easy, convenient place for them to share their pictures with me.
After doing some research, I came across the perfect solution: immich!
Well, almost perfect. The main downside is that it would run on my home server, so I'd have to be careful about how I expose it. I came up with a few requirements:
- Only trusted individuals should be able to make any contact with my home network. There have been cases of attackers using insecure, publicly facing software to infiltrate home networks. I don't personally want to take that risk.
- Trusted individuals should be able to access immich from a normal browser. While I’m comfortable setting up Wireguard, others are not.
- I want to quickly/easily be able to update who is and isn't allowed to use the service, without logging into a machine.
How I solved the problem using ngrok
The first thing I need to do is pick a login method. While it would be super simple to just pick Google OAuth, it kind of defeats the purpose. Instead, I'll use SimpleLogin. It's a little more setup, but it's free and lets me use my Proton address. ngrok
has an OIDC
Traffic Policy action, meaning I can set up OIDC with a few lines of YAML!
From there, I check that the email of the logged-in user exists in a list of all allowed users, created using the set-vars
action. If not, they are rejected, ensuring unauthorized users never contact my home network. From there, ngrok routes traffic to an internal endpoint, depending on the host (service) they are trying to reach. Each service also has its own access list.
The internal endpoints are initiated by agents that sit in my home network. They do nothing more than forward the traffic to the appropriate local IP/port, meaning I only ever have to interact with the local machine to occasionally update the system/ngrok
client.
The coolest thing about this setup is that immich can be configured with OAuth as well! By configuring immich with the same OAuth settings as our OIDC action and turning on Auto Start
, they will be transparently logged into immich after authenticating at my ngrok
-powered gateway.
Even better, by turning on Auto Register
, an account will be automatically created for them upon first login! This means I can manage my users solely through the immich_users
variable in my traffic policy. When I want to add a new user, I add their protonmail address to that list, they sign in, and they can immediately start updating and sharing!
Limitations
The biggest limitation of my current approach is that the immich mobile app does not work with this setup. It wants to connect to the server (unauthenticated) to obtain OIDC information. While I could update my policy to allow those particular routes to be accessed without authentication, I would rather err on the side of security. As someone who does not use a phone much, I can live with the tradeoff.
Gateway config with a Cloud Endpoint:
on_http_request:
- name: "basic firewall"
actions:
- type: "custom-response"
config:
content: "Denied! Bye Bye!"
status_code: 401
expressions:
- conn.client_ip.geo.location.country_code != 'US' || conn.client_ip.is_on_blocklist || conn.client_ip.is_tor_node
- name: "set up user access groups"
actions:
- type: "set-vars"
config:
vars:
- main_user: "mainuser@protonmail.com"
- immich_users: ['${vars.main_user}','friend1@protonmail.com','friend2@protonmail.com']
- proxmox_users: ['${vars.main_user}', 'roommate1@protonmail.com', 'roommate2@protonmail.com']
- name: "OIDC with simplelogin"
actions:
- type: "openid-connect"
config:
issuer_url: "<https://app.simplelogin.io>"
client_id: "sample-client-id"
client_secret: "${secrets.get('prod-vault', 'simplelogin-client-secret')}"
- name: "immich forward"
actions:
- type: "forward-internal"
config:
url: "<https://example-immich.internal>"
expressions:
- "(actions.ngrok.oidc.identity.email in vars.immich_users)"
- "endpoint.host == 'immich.example.com'"
- name: "rss reader forward"
actions:
- type: "forward-internal"
config:
url: "<https://example-freshrss.internal>"
expressions:
- "(actions.ngrok.oidc.identity.email == vars.main_user)"
- "endpoint.host == 'freshrss.example.com'"
- name: "proxmox forward"
actions:
- type: "forward-internal"
config:
url: "<https://example-proxmox.internal>"
expressions:
- "(actions.ngrok.oidc.identity.email in vars.proxmox_users)"
- "endpoint.host == 'proxmox.example.com'"
- name: "default route (DENY)"
actions:
- type: "custom-response"
config:
content: >
example.com is currently unused.
status_code: 200
Agent configs:
version: 3
agent:
authtoken: supersecrettoken
endpoints:
- name: immich
url: <https://example-immich.internal>
upstream:
url: 192.168.1.29:8080
- name: freshrss
url: <https://example-freshrss.internal>
upstream:
url: 192.168.1.29:6342
- name: proxmox
url: <https://example-proxmox.internal>
upstream:
url: 192.168.1.111:80
Euan (Principal Software Engineer): Serverless URL shortener for ngrokkers
I used to run a URL shortener in college. That was a mistake, but you know, once you start running something you can't stop running it, so I've been running it forever, ever since then. It would be nice to run it with no servers involved because maintaining servers is fun, but not maintaining them is also fun.
Since I started working at ngrok, I noticed that many work-related URLs are long and hard to read, like https://www.notion.so/ngrok/$PAGE-TITLE-$super-long-uuid-$super-long-hash-uuid
. go.ngrok.pizza/page-title
is way shorter.
It seemed like a fun challenge to try and make an fully functioning URL shorterner with no external dependencies, and so I ended up with the following policy.
This uses ngrok’s Internal Endpoints as the sorta key/value database, which is written to with the http-request
action, and read with the forward-internal
action.
I won’t exactly recommend using anything like this, but I do think it’s a neat hack!
Gateway config + shortener logic with a Cloud Endpoint:
on_http_request:
- actions:
- type: "oauth"
config:
provider: "google"
- name: ngrok-only
expressions:
- "!actions.ngrok.oauth.identity.email.endsWith('@ngrok.com')"
actions:
- type: "deny"
- name: create-new
expressions: [ "req.method == 'POST'" ]
actions:
- type: http-request
config:
url: "https://api.ngrok.com/endpoints"
method: "POST"
headers:
Authorization: "Bearer <secret goes here>"
Content-Type: "application/json"
Ngrok-Version: "2"
body: |-
{
"description": "go places target",
"metadata": "Created by ${actions.ngrok.oauth.identity.email}",
"bindings": ["internal"],
"url": "https://${req.url.query_params['slug'][0]}.go.internal",
"traffic_policy": "{\"on_http_request\": [{\"actions\": [{\"type\": \"redirect\",\"config\": {\"to\": \"${req.url.query_params['url'][0]}\"}}]}]}"
}
- type: custom-response
config:
status_code: 200
headers:
content-type: "text/html"
body: |-
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>URL Shortener</title>
</head>
<body>
URL: <a href="https://${endpoint.host}/${req.url.query_params['slug'][0]}">https://${endpoint.host}/${req.url.query_params['slug'][0]}</a>
</body>
</html>
- name: redirect
expressions: ["req.url.path != '/'"]
actions:
- type: forward-internal
config:
url: "https://${req.url.path.substring(1)}.go.internal"
on_error: continue
- type: custom-response
config:
status_code: 404
headers:
content-type: "text/html"
body: |-
not found
- name: default
actions:
- type: custom-response
config:
status_code: 200
headers:
content-type: "text/html"
body: |-
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>URL Shortener - ngrok Internal</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #333; background-color: #f4f4f4; padding: 20px; } .container { max-width: 600px; margin: 0 auto; background-color: #fff; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } h1 { color: #2c3e50; margin-bottom: 10px; text-align: center; } .subtitle { text-align: center; color: #7f8c8d; margin-bottom: 30px; font-size: 14px; } .info-box { background-color: #e8f4f8; border-left: 4px solid #3498db; padding: 15px; margin-bottom: 30px; border-radius: 4px; } .info-box h3 { margin-bottom: 8px; color: #2c3e50; } .info-box p { color: #555; font-size: 14px; line-height: 1.5; } form { display: flex; flex-direction: column; gap: 20px; } .form-group { display: flex; flex-direction: column; gap: 8px; } label { font-weight: 600; color: #2c3e50; font-size: 14px; } .label-hint { font-weight: normal; color: #7f8c8d; font-size: 12px; } input { padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 16px; transition: all 0.3s ease; } input:focus { outline: none; border-color: #3498db; box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); } button { background-color: #3498db; color: white; padding: 12px 24px; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; } button:hover { background-color: #2980b9; transform: translateY(-1px); box-shadow: 0 2px 5px rgba(0,0,0,0.2); } button:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(0,0,0,0.2); } #out { margin-top: 20px; padding: 15px; border-radius: 6px; font-size: 14px; word-break: break-all; } #out.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } #out.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
</style>
</head>
<body>
<div class="container">
<h1>URL Shortener</h1>
<p class="subtitle">ngrok Internal Tool</p>
<div class="info-box">
<h3>How it works</h3>
<p>Create short, memorable links that redirect to any URL. Perfect for sharing long or complex URLs in a more user-friendly format.</p>
</div>
<form id="form" action="/create" method="POST">
<div class="form-group">
<label for="slug">
Short Link Name
<span class="label-hint">(e.g., "team-meeting" or "project-docs")</span>
</label>
<input type="text" id="slug" name="slug" placeholder="Enter a memorable name" required>
</div>
<div class="form-group">
<label for="url">
Destination URL
<span class="label-hint">(The full URL where the short link will redirect)</span>
</label>
<input type="url" id="url" name="url" placeholder="https://example.com/very/long/url" required>
</div>
<button type="submit">Create Short Link</button>
</form>
<div id="out"></div>
</div>
<script>
document.querySelector('#form').addEventListener('submit', async function(event) {
event.preventDefault(); // Prevent the default form submission
const slug = document.querySelector('#slug').value;
const url = document.querySelector('#url').value;
let resp = await fetch(window.location.href + '?slug=' + encodeURIComponent(slug) + '&url=' + encodeURIComponent(url), {
method: 'POST',
});
if (resp.ok) {
document.querySelector("#out").innerHTML = await resp.text();
document.querySelector("#out").className = 'success';
} else {
document.querySelector("#out").innerHTML = await resp.error();
document.querySelector("#out").className = 'error';
}
});
</script>
</body>
</html>
Time to become an ngrokker in policy
We can’t officially call your setup an attempt at “homefooding” (well, unless you join us, and yes, I’m still workshopping the name), but we’d love to have you building gateways and policies alongside us.
Here’s how:
- Get started with your first
custom-response
policy as a small testbed. - Explore other gateway shapes and patterns in our examples gallery.
- Learn how ngrokkers write scalable policies with chaining, grouping, and catch-alls… all of which are employed marvelously in the snippets abode.
- Ask questions or share your setup on Discord, where you’ll find a few of these engineers and more.