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.
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
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
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
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
in order to only use it when necessary, with an
1apiVersion: cert-manager.io/v12kind: ClusterIssuer3metadata:4 name: letsencrypt-prod5spec:6 acme:7 email: [email protected]8 solvers:9 - http01:10 ingress:11 class: nginx12 - dns01:13 cloudflare:14 apiTokenSecretRef:15 key: api_key16 name: cloudflare-api-token17 email: [email protected]18 selector:19 dnsNames:20 - share.example.com21 - '*.share.example.com'
Make sure to replace
spec.acme.email with your email address,
with your Cloudflare email address (if you're using Cloudflare), and
spec.acme.solvers.selector.dnsNames with your domain name.
Additionally, you'll notice that I reference a secret called
spec.acme.solvers.cloudflare.apiTokenSecretRef). This is a secret containing my Cloudflare API
token, allowing cert-manager to create the DNS record for validation.
provides an excellent reference for how to create both the API token and this secret (just make sure
Secret name matches in the ref, since my example and the docs use different names).
Creating the Expose
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
and rely on cert-manager's annotations to provision
the TLS certificate.
Let's start with the
1kind: PersistentVolumeClaim2apiVersion: v13metadata:4 name: expose-tunnel-db5spec:6 accessModes:7 - ReadWriteOnce8 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
1k create secret generic --from-literal=username=phroggyy --from-literal=password=$PASSWORD expose-auth
Next, we need to create the
1kind: Service2apiVersion: v13metadata:4 name: expose-tunnel5spec:6 selector:7 app: expose-tunnel8 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-issuerannotation to tell cert-manager which issuer to use for this
- We have two
Ingress, one for the root domain and one for the wildcard subdomain, ensuring that routing works for both.
- We have a
tlssection, 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.
There's only one step left: configure
expose on your local machine to use your self-hosted Expose
If you haven't already, go ahead and install the
1composer global require beyondcode/expose
And ensure you have composer's global bin directory in your
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
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.