In part 1, Hetzner stood as an affordable, sovereign base; in part 2, Terraform as memory and insurance. Now the last building block: small functions — without leaving the stack for it.
Cloudflare Workers are technically excellent, there is nothing to argue about there. It is just that "our logic runs at a US edge provider" now causes unease at some companies. So the question is not "is Cloudflare bad" — it is not. The question is: can I get a function behind a URL just as easily, without leaving my stack? The answer is yes, and it is unspectacular.
A function is just a small container
Let us de-mystify the word. A "function" is at its core a small HTTP endpoint that does something and answers. At an edge provider it is nicely wrapped. In house it is a small Docker container next to the app — no more.
In my case it looks like this: a pnpm repository, several functions in it, one Dockerfile. The part that is otherwise annoying is taken over by Dokploy: the continuous deployment and update of the Docker images — push, build, rollout, a domain in front, restart. Exactly the comfort you appreciate about Workers — only on the box from part 1, provisioned as in part 2.
Whoever does not want to give up the Worker programming model does not have to: a lean framework like Hono runs on Cloudflare Workers just as it does, unchanged, in a Node or Bun container. You write the same route — and only decide at deployment where it runs. More of a FaaS feeling would be possible with Nitro or a self-hosted OpenFaaS; mostly it is not worth it.
Example: two functions behind one URL
Concrete and minimal: one endpoint that exports data as CSV, and one that just forwards a trigger. Both in a small service, via Docker Compose, rolled out by Dokploy in Swarm mode.
import { Hono } from 'hono'
const app = new Hono()
// GET /export.csv -> serve data as CSV
app.get('/export.csv', async (c) => {
const rows = await loadOrders() // no matter the source: DB, API, file
const lines = [['id', 'total', 'created'].join(',')]
for (const r of rows) lines.push([r.id, r.total, r.created].join(','))
return c.body(lines.join('\n'), 200, {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename=export.csv',
})
})
// POST /trigger -> just forward the event
app.post('/trigger', async (c) => {
const payload = await c.req.json()
await fetch(process.env.DOWNSTREAM_URL, {
method: 'POST',
body: JSON.stringify(payload),
})
return c.json({ ok: true })
})
export default appservices:
functions:
build: .
image: registry.example.com/functions:latest
environment:
- DOWNSTREAM_URL=https://intern.example.com/webhook
deploy:
replicas: 1 # Dokploy: Swarm mode
restart_policy:
condition: anyThat is all it is. One repo, one Dockerfile, one Compose service. Dokploy puts the domain in front and keeps the image current. What was nice about Workers — "code in, URL out" — stays. Only the operator is you.
What you gain
- Cost control. No pay-per-request surprise egg. The baseline noise is the box that is already running anyway.
- No cold-start problems. The container runs. Period.
- Sovereignty. Code and data stay where the rest also lives — and that is again the argument carrying this whole series.
- Logs and debugging in one place, not in a foreign console.
And the sober part: there is plenty of heavy serverless machinery. Often a container with the necessary API behind it is simply enough. You do not have to take the big solution just because it exists.
What you give up
You give up the actual serverless promise: "no servers". You pay a baseline noise — the box runs whether one request comes or a thousand. With a load profile that has long idle phases, real pay-per-request can be cheaper.
And the reflex from part 1 applies here just the same: if the functions are called worldwide, you run into latency problems. A single container in Nuremberg is no edge network for users in Singapore. If global low latency is a real requirement, edge is the right choice — then the renunciation is not defensible. For most mid-market functions — internal tools, exports, webhooks, triggers — it very much is.
Conclusion — the stack stands
With that the series closes. Hetzner as an affordable, sovereign base. Terraform as memory and insurance. Functions as small containers in the same setup. The result: even the most varied system landscapes — website, CMS, backends, functions, cron — can be covered without switching provider. Everything at one host, in the EU, in Germany.
If that is exactly what you want — a modern stack that stays in the EU and that a small team can carry: the build, or the cleanup of a grown one, is part of my work in Technical Consulting.