How I expose local sites with a self-hosted tunnel

Leo Sjöberg • May 4, 2023

Recently, I've been working on slackbot side projects (Decisionlog and Summarizer). Being Slackbots, they need to be able to receive requests from Slack. So, in order to develop them, I need to be able to receive these requests not just in a remote environment (which would require deploying every time I want to test something), but also locally. This is where secure tunnels like ngrok come in. Now, I'm a nerd, and I don't like paying for things I can self-host. So, I decided to self-host a secure tunnel service, Expose, on my Kubernetes cluster.

By now, a lot of people have heard of ngrok, a tool that allows you to expose local sites to the internet. Expose is an ngrok competitor, which is open source, but also has a paid alternative. The paid plan is extremely competitive at only $59/year, giving you reserved subdomains as well as fully white label domains. However, you can also spend hours of work and over $100/y hosting it yourself, which is clearly the better option.

The setup

I'm running a Kubernetes cluster on Civo for my personal playground. It's a managed Kubernetes service, which means I don't have to worry about the control plane, and I can just focus on the worker nodes. I'm running a 1-node cluster, with 1 vCPUs and 2GB of RAM. Tiny, but enough for a playground (and for running an Expose server).

On this cluster, I run ingress-nginx to receive incoming traffic. I also run cert-manager to automatically provision TLS certificates for my domains. These are both installed with helm, and I won't go into detail on that as it's pretty straightforward; just run helm install:

1helm repo add ingress-nginx
2helm repo add jetstack
3helm repo update
4helm install ingress-nginx ingress-nginx/ingress-nginx --namespace ingress-nginx --create-namespace
5helm install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace --set installCRDs=true

Deploying the Expose server

The Expose server is a simple deployment, with a single replica. It's exposed to the internet through an ingress, and it's configured to use a TLS certificate provisioned by cert-manager. One thing that's a bit special about deploying Expose is that, since it creates a tunnel on a given subdomain, you need to use a wildcard TLS certificate, covering both your root ( and your subdomains (* Additionally, you need to configure the ingress to route traffic for both the root and all subdomains.

Configuring the Cert Manager ClusterIssuer

To get a wildcard TLS certificate, you need to use DNS validation. This requires you to customise your issuer, such that cert-manager can create the requisite DNS record. You probably also want to configure the issuer such that it only uses DNS validation when necessary, relying on HTTP-01 in other instances (this limits the amount of control you need to hand over). I'm using Cloudflare to manage my DNS, but the cert-manager docs cover all different providers here.

The only thing you need to ensure with this kind of setup is to use a selector on the dns01 solver in order to only use it when necessary, with an http01 fallback:

2kind: ClusterIssuer
4 name: letsencrypt-prod
6 acme:
8 solvers:
9 - http01:
10 ingress:
11 class: nginx
12 - dns01:
13 cloudflare:
14 apiTokenSecretRef:
15 key: api_key
16 name: cloudflare-api-token
18 selector:
19 dnsNames:
20 -
21 - '*'

Make sure to replace with your email address, spec.acme.solvers[1] with your Cloudflare email address (if you're using Cloudflare), and spec.acme.solvers[1].selector.dnsNames with your domain name.

Additionally, you'll notice that I reference a secret called cloudflare-api-token (in spec.acme.solvers[1].cloudflare.apiTokenSecretRef). This is a secret containing my Cloudflare API token, allowing cert-manager to create the DNS record for validation. The documentation provides an excellent reference for how to create both the API token and this secret (just make sure the Secret name matches in the ref, since my example and the docs use different names).

Creating the Expose Deployment and Ingress

If you just want the code, you can find all manifests on GitHub.

Now that we have our ClusterIssuer configured, we can create the Expose Deployment and Ingress, and rely on cert-manager's annotations to provision the TLS certificate.

Let's start with the Deployment:

1kind: PersistentVolumeClaim
2apiVersion: v1
4 name: expose-tunnel-db
6 accessModes:
7 - ReadWriteOnce
8 resources:
9 requests:
10 storage: 4Gi
12apiVersion: apps/v1
13kind: Deployment
15 labels:
16 app: expose-tunnel
17 name: expose-tunnel
19 replicas: 1
20 selector:
21 matchLabels:
22 app: expose-tunnel
23 template:
24 metadata:
25 labels:
26 app: expose-tunnel
27 spec:
28 containers:
29 - env:
30 - name: port
31 value: "8080"
32 - name: domain
33 value:
34 - name: username
35 valueFrom:
36 secretKeyRef:
37 name: expose-auth
38 key: username
39 - name: password
40 valueFrom:
41 secretKeyRef:
42 name: expose-auth
43 key: password
44 image:
45 imagePullPolicy: IfNotPresent
46 name: expose
47 volumeMounts:
48 - mountPath: /root/.expose
49 name: db-vol
50 volumes:
51 - name: db-vol
52 persistentVolumeClaim:
53 claimName: expose-tunnel-db

First and foremost, you'll notice a PersistentVolumeClaim. This is required by Expose, as it uses a SQLite database as its storage backend.

You may also notice that the deployment references a Secret to hold the username and password, which will be used to create an expose user for you.

I personally use a UUID as the password, which you can generate using the following:

1PASSWORD=$(uuidgen | tr -d '\n' | tr '[:upper:]' '[:lower:]')

Then, you can create the secret using the kubectl CLI:

1k create secret generic --from-literal=username=phroggyy --from-literal=password=$PASSWORD expose-auth

Next, we need to create the Ingress and Service:

1kind: Service
2apiVersion: v1
4 name: expose-tunnel
6 selector:
7 app: expose-tunnel
8 ports:
9 - protocol: TCP
10 port: 80
11 targetPort: 8080
13kind: Ingress
16 annotations:
17 letsencrypt-prod
18 name: expose-tunnel
20 ingressClassName: nginx
21 rules:
22 - host:
23 http:
24 paths:
25 - backend:
26 service:
27 name: expose-tunnel
28 port:
29 number: 80
30 path: /
31 pathType: Prefix
32 - host: "*"
33 http:
34 paths:
35 - backend:
36 service:
37 name: expose-tunnel
38 port:
39 number: 80
40 path: /
41 pathType: Prefix
42 tls:
43 - hosts:
44 -
45 - "*"
46 secretName:

This is where the bulk of the magic happens. In particular:

  1. We use the annotation to tell cert-manager which issuer to use for this Ingress.
  2. We have two rules in the Ingress, one for the root domain and one for the wildcard subdomain, ensuring that routing works for both.
  3. We have a tls section, which tells cert-manager to provision a single TLS certificate for both the root domain and the wildcard subdomain.

Applying all of these manifests will result in a working Expose deployment, with a TLS certificate provisioned by cert-manager.

Configuring Expose

There's only one step left: configure expose on your local machine to use your self-hosted Expose instance.

If you haven't already, go ahead and install the expose CLI:

1composer global require beyondcode/expose

And ensure you have composer's global bin directory in your $PATH:

1export PATH=~/.composer/vendor/bin:$PATH

Then, run expose publish to publish the global config file. The file will be created at ~/.expose/config.php, and contains a PHP array with your configuration.

In order to use your self-hosted Expose instance, you'll need to update the value to your host, in my case Note that this should be the root domain, not the wildcard subdomain.

Finally, set the auth_token value to the password you generated earlier.

That's it! You should now be able to run expose share 8080 and have your local port exposed to the internet. Use the --subdomain flag to specify a custom subdomain, or omit it to get one generated.