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 https://kubernetes.github.io/ingress-nginx2helm repo add jetstack https://charts.jetstack.io3helm repo update4helm install ingress-nginx ingress-nginx/ingress-nginx --namespace ingress-nginx --create-namespace5helm 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
(share.example.com
) and your subdomains (*.share.example.com
). 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:
1apiVersion: cert-manager.io/v1 2kind: ClusterIssuer 3metadata: 4 name: letsencrypt-prod 5spec: 6 acme: 8 solvers: 9 - http01:10 ingress:11 class: nginx12 - dns01:13 cloudflare:14 apiTokenSecretRef:15 key: api_key16 name: cloudflare-api-token18 selector:19 dnsNames:20 - share.example.com21 - '*.share.example.com'
Make sure to replace spec.acme.email
with your email address, spec.acme.solvers[1].cloudflare.email
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 3metadata: 4 name: expose-tunnel-db 5spec: 6 accessModes: 7 - ReadWriteOnce 8 resources: 9 requests:10 storage: 4Gi11---12apiVersion: apps/v113kind: Deployment14metadata:15 labels:16 app: expose-tunnel17 name: expose-tunnel18spec:19 replicas: 120 selector:21 matchLabels:22 app: expose-tunnel23 template:24 metadata:25 labels:26 app: expose-tunnel27 spec:28 containers:29 - env:30 - name: port31 value: "8080"32 - name: domain33 value: share.phroggyy.dev34 - name: username35 valueFrom:36 secretKeyRef:37 name: expose-auth38 key: username39 - name: password40 valueFrom:41 secretKeyRef:42 name: expose-auth43 key: password44 image: ghcr.io/beyondcode/expose/expose-server:1.3.245 imagePullPolicy: IfNotPresent46 name: expose47 volumeMounts:48 - mountPath: /root/.expose49 name: db-vol50 volumes:51 - name: db-vol52 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 3metadata: 4 name: expose-tunnel 5spec: 6 selector: 7 app: expose-tunnel 8 ports: 9 - protocol: TCP10 port: 8011 targetPort: 808012---13kind: Ingress14apiVersion: networking.k8s.io/v115metadata:16 annotations:17 cert-manager.io/cluster-issuer: letsencrypt-prod18 name: expose-tunnel19spec:20 ingressClassName: nginx21 rules:22 - host: share.phroggyy.dev23 http:24 paths:25 - backend:26 service:27 name: expose-tunnel28 port:29 number: 8030 path: /31 pathType: Prefix32 - host: "*.share.phroggyy.dev"33 http:34 paths:35 - backend:36 service:37 name: expose-tunnel38 port:39 number: 8040 path: /41 pathType: Prefix42 tls:43 - hosts:44 - share.phroggyy.dev45 - "*.share.phroggyy.dev"46 secretName: share.phroggyy.dev-tls
This is where the bulk of the magic happens. In particular:
- We use the
cert-manager.io/cluster-issuer
annotation to tell cert-manager which issuer to use for thisIngress
. - We have two
rules
in theIngress
, one for the root domain and one for the wildcard subdomain, ensuring that routing works for both. - 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 servers.main.host
value to your host, in my case share.phroggyy.dev
. 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.