Introduction
I run a small automation server at home that powers a few cron jobs and a private API for my own website. The site lives on Netlify, which gives me serverless functions for things like contact‑form handling. The challenge was: how do I call my home server from a Netlify function when the tunnel address changes every time I restart it?
In this post I walk through the exact steps I took, the code I wrote, and the pitfalls I hit along the way. Everything here is what I actually use for Dream Futures Tech – no fictional benchmarks or client data.
Why Netlify + Cloudflare Tunnel?
Netlify handles static assets and serverless functions with zero‑ops deployment. My home server runs on a modest Raspberry Pi, so exposing it directly to the internet would require port forwarding and a static IP – both of which I prefer to avoid for security and reliability reasons.
Cloudflare Tunnel (formerly Argo Tunnel) creates a secure outbound connection from my Pi to Cloudflare’s edge network. Cloudflare then provides a public hostname (e.g., myhome.example.com) that forwards traffic back through the tunnel. The tunnel can be started with cloudflared, a lightweight binary.
The snag: each time I restart the tunnel, Cloudflare may assign a new sub‑domain unless I reserve a custom hostname. I wanted a solution that works with the free tier, so I let Cloudflare generate the hostname and then discover it programmatically from my Netlify function.
Setting Up the Cloudflare Tunnel
On the Pi I installed cloudflared and created a configuration file that runs the tunnel as a background service.
# /home/pi/.cloudflared/config.yml
url: http://localhost:3000 # my internal API
# No hostname – let Cloudflare assign one
logfile: /var/log/cloudflared.log
loglevel: info
I start it with:
cloudflared tunnel run myhome-tunnel &
The process writes a line like:
2023-09-01T12:34:56Z INF Starting tunnel myhome-tunnel
2023-09-01T12:34:56Z INF Tunnel URL: https://random-12345.trycloudflare.com
That URL is the address I need to call from Netlify.
Discovering the Dynamic Tunnel URL
Instead of parsing the log file (which can be flaky), I use Cloudflare’s API to list active tunnels and pull the public URL.
API Token
I created a token with the Zone:Read and Tunnel:Read permissions scoped to my account. The token lives in Netlify’s environment variables as CLOUDFLARE_TOKEN.
Helper Script (optional)
Locally I wrote a tiny Node script to verify the API call:
// scripts/get-tunnel-url.js
const fetch = require('node-fetch');
const token = process.env.CLOUDFLARE_TOKEN;
(async () => {
const res = await fetch('https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
const tunnel = data.result.find(t => t.name === 'myhome-tunnel');
console.log(tunnel?.tunnel_url);
})();
Running this prints the current public URL, e.g., https://random-12345.trycloudflare.com.
Netlify Function that Calls the Home Server
The function lives in netlify/functions/ping-home.js. It does three things:
- Fetch the tunnel URL from Cloudflare’s API.
- Append the path I want to hit on the home server (
/status). - Proxy the response back to the client.
// netlify/functions/ping-home.js
const fetch = require('node-fetch');
exports.handler = async function(event, context) {
const token = process.env.CLOUDFLARE_TOKEN;
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
// 1⃣ Get the tunnel URL
const tunnelRes = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const tunnelData = await tunnelRes.json();
const tunnel = tunnelData.result.find(t => t.name === 'myhome-tunnel');
if (!tunnel) {
return { statusCode: 502, body: 'Tunnel not found' };
}
const homeUrl = `${tunnel.tunnel_url}/status`;
// 2⃣ Call the home server
const homeRes = await fetch(homeUrl);
const body = await homeRes.text();
// 3⃣ Return the result
return {
statusCode: homeRes.status,
headers: { 'Content-Type': homeRes.headers.get('content-type') || 'text/plain' },
body,
};
};
Important notes:
- The function runs on Netlify’s edge, so the outbound request must be over HTTPS. Cloudflare’s tunnel URL is always HTTPS, which satisfies that.
- I keep the token and account ID in Netlify’s UI under Site Settings → Build & Deploy → Environment. Never commit them to source control.
Handling Tunnel Restarts
Because the tunnel URL can change, the function always queries Cloudflare at runtime. That adds a few milliseconds of latency, but the overhead is negligible for my use case (a simple health‑check endpoint). If the latency ever becomes a concern, I could cache the URL in Netlify’s Edge Functions KV store for a short period (e.g., 30 seconds).
Testing the End‑to‑End Flow
- Deploy the Netlify site (
netlify deploy --prod). - Hit
https://my-site.netlify.app/.netlify/functions/ping-homein a browser or withcurl. - You should see the JSON payload returned by the home server’s
/statusroute.
If the tunnel is down, the function returns a 502 with the message “Tunnel not found”. This makes it easy to set up alerts in Netlify’s monitoring UI.
Lessons Learned
- Never rely on a static tunnel URL when using the free Cloudflare tier. The API is the reliable source of truth.
- Environment variables are your friend – keep secrets out of the repo and rotate them periodically.
- Graceful error handling matters. A missing tunnel should surface as a clear HTTP status rather than a generic 500.
- Keep the home server lightweight. My Pi only runs a Node/Express app on port 3000, which is more than enough for the occasional webhook or status check.
Conclusion
By combining Netlify’s serverless functions with Cloudflare Tunnel’s dynamic public endpoint, I now have a secure, low‑maintenance bridge from my static site to a private home automation server. The pattern works for any scenario where you need to call a self‑hosted service from a cloud function without exposing a permanent public IP.
I build sites and automations for small businesses at dreamfuturestech.com.
Top comments (0)