Danish Ali Siddiqui built Doorbell, a webhook tunnel that keeps accepting requests while your laptop is shut. Along the way he hit four rough edges on Zerops, let's discuss these!
Over the weekend of August 8 and 9, Zerops ran The Zerops Challenge in collaboration with WeMakeDevs. Forty eight hours, solo entries only, and one hard rule: the finished thing had to be a real application deployed on Zerops with a live URL, still running when the judges reached it. Not a local demo, not a screen recording.
In three days the challenge brought Zerops over 1,000 new sign-ups, triggered more than 10,000 build and deployment pipelines, and produced 250+ valid projects. Two tracks each had a winner, and a $5,000 credits pool was split across the standout projects (overall 7 big winners). One of them was Doorbell, by Danish, and it is the entry that gave us the most uncomfortable and most useful feedback of the whole weekend. We are publishing his answers close to verbatim, including the parts that do not flatter us.
First, introduce yourself. I'm Danish, an AI/ML engineer working with a Riyadh-based company in Saudi Arabia. I build enterprise AI solutions, backend systems, workflow automations, and full-stack applications. I enjoy working on practical engineering problems where reliability matters as much as functionality.
What did you build, and where did the idea come from? I built Doorbell, a self-hosted webhook tunnel that keeps accepting requests even when your laptop is offline.
The idea came from a common frustration in webhook development. Services like Stripe or GitHub need a public endpoint, but when you close your laptop or lose connection, a normal tunnel returns an error and the request can be lost. A third-party tunnel provider that wanted to hold those payloads for you would be holding your signatures and tokens on its own infrastructure.
Doorbell solves this by keeping the database under your control. It writes each request to Postgres before responding with HTTP 202. When the local client reconnects, the queued requests are delivered in the same order they arrived.
Walk us through the architecture. The CLI maintains a single outbound TCP connection to the gateway's raw public port. Each incoming request becomes a yamux stream over that connection, and Go's httputil.ReverseProxy forwards the traffic through it. This supports chunked request bodies, keep-alive connections, and WebSocket upgrades without requiring a custom framing protocol.
The deployment contains three services defined in one YAML file: a Go gateway, Postgres for data that must survive restarts, such as reserved names and stored request bodies, and Valkey for temporary shared state, such as identifying which container currently owns a tunnel connection.
The mailbox is the most important part of the architecture. A request is stored before the sender receives a response, preventing data loss. An atomic lease prevents concurrent drains from delivering the same queued request twice. Sensitive signing headers are redacted before storage by matching the shape of the header name rather than naming each vendor, so the next provider is covered before anyone has heard of it. The trade-off is documented clearly: a queued webhook is delivered without its original signing header, so a signature check on a held request will fail.
What was the easiest part? The import YAML. One pasted file gave me a Go service, Postgres, and Valkey on a private network in ninety seconds. Service-to-service environment interpolation, ${db_connectionString}, meant I did not have to write any networking configuration.
And what did Zerops get wrong? How did you catch it? The difficult issues were not obvious failures. They were silent platform behaviours that initially looked like problems in my own code.
The one my entire project depends on is the raw public TCP port. It cannot be declared in the import YAML, so after the "one pasted file" setup, the port still has to be opened manually in the GUI. A shared IPv4 cannot carry one at all, and the API rejects it with publicIpTypeNotSupported. The options are IPv6 or a dedicated IPv4, and during the weekend I could not work out whether the dedicated address could be paid for from credit. I stayed on IPv6. The HTTP side of the gateway is unaffected, because that goes through the shared balancer: webhooks, the dashboard and the live URL all work fine over IPv4. What IPv6-only costs is the one step that shows the whole idea, which is opening a tunnel of your own with the CLI.
Two other issues cost me about an hour each. The load balancer defaults send_timeout to two seconds, so my SSE dashboard connection was terminated whenever the stream went quiet for that long, until I added a heartbeat frame. The balancer also replaces the response body for any 5xx error, which prevented Doorbell's own error message, nothing is listening on 127.0.0.1:3000, from reaching the user. What arrives instead is the balancer's own advice to check the port, minus the port it actually tried. I discovered that one by following my own README from the perspective of a new user.
What helped most was replacing assumptions with direct verification. I wrote small scripts to test the live API and kept them in the repository under tools/verify.
Did the docs help? They helped on the happy path. What cost me time was mostly documented, just not where I ran into it. The shared IPv4 restriction is in the Public Access reference, and the send_timeout default is a row in the balancer configuration table. Neither shows up in the port routing screen or in the import file, which is where you are standing when it matters. The 5xx response-body replacement I could not find written down anywhere. So I verified behaviour directly against the live API using throwaway scripts. They are still in my repository under tools/verify, which says a lot.
What did you not understand when you first opened Zerops? I initially assumed the *.zerops.app subdomain was the public entry point for every service in the project, including raw TCP ports. It is actually a shared HTTP balancer, while a raw port is exposed through the project's own public IP. I spent a while testing the wrong address and assuming the fault was in my implementation.
Once I understood that distinction, several issues that had appeared unrelated became much easier to diagnose.
What do you wish existed?
- Public port routing in the import YAML. The docs say routing capabilities are on the roadmap. Until they land, the "one pasted file" experience stops one step short for any project that needs a public raw port.
- A plain statement of whether the dedicated IPv4 add-on can be paid for from credit. I lost time trying to determine that during the weekend and never settled it.
- The raw TCP facts gathered where you meet them: that a shared IPv4 will not carry a raw port, that the *.zerops.app subdomain will not either, and what IPv6-only access means for the people using your service. It exists in the networking reference. It does not exist in the port routing screen.
- send_timeout explained in a streaming context rather than only as a row in a configuration table. Two seconds is the default, it is raisable, and nothing connects those facts to SSE or WebSocket work.
Did you use ZCP? I did not, so I cannot fairly judge it. I was building a Go service that maintains a long-lived TCP socket, so I needed a tight local development loop with the race detector and integration tests against real Postgres and Valkey instances. Deployment through zerops-import.yml and zcli push was painless.
Any advice for people just starting out their deployment journey? Verify platform behaviour before making it part of your architecture. If a capability is essential to the design, test it directly instead of relying on assumptions.
Deploy on the first day, not the final night. Infrastructure constraints often affect the design itself, so discovering them early gives you time to adapt properly.
Finally, follow your own README exactly as a new user would. Running every command in order helped me find two broken commands and one promise the deployed application did not keep.
What we are taking from this
Danish also wrote something we want to quote, because it is the clearest statement of the thing we are usually trying to explain at much greater length:
I chose Zerops because it was the only platform where this project could exist as designed: a raw public TCP port, a continuously running process, and private managed Postgres and Valkey together. Serverless was structurally unsuitable. That combination, deployable from one file, is a strong differentiator.
That is the argument. A function stops when it answers, and nothing is left holding the socket. Doorbell needs something holding the socket, and that is not a preference, it is the feature.
Which makes the rest of his feedback sharper rather than softer. The ninety second path from one pasted file to three networked services is exactly the experience we want. The problem is what happens the moment a project needs a raw public port: the config file stops being sufficient, you drop into the GUI, and then you meet an IP type constraint that is written down in the networking reference and nowhere near the screen where you hit it. The gap between "this platform is the only one that can express my design" and "I cannot find out how to expose it" is the worst possible place to lose someone, and it is entirely our fault, not theirs.
His four asks are all reasonable and all specific. Two of them are placement problems rather than missing pages: the raw TCP and IP type facts and the send_timeout default are both documented, and both are absent from the moment a developer needs them. The send_timeout default in particular silently breaks a common pattern. It is two seconds, it is raisable to as much as five minutes in the balancer configuration, and a developer running SSE should not have to reverse engineer "the platform terminated your stream" from connection behaviour before finding that out. The 5xx body replacement is the one we genuinely never wrote down, and Danish is right that swallowing an application's own error message is worse than saying nothing.
And the IPv6 detail deserves to be said plainly rather than buried. The reachable, working parts of Doorbell were reachable over IPv4 the whole time, because the HTTP balancer handles them. The one action that demonstrates the entire premise, opening your own tunnel, needed IPv6, because a raw port cannot go through that balancer and a shared IPv4 will not carry one.
Congratulations, Danish. Well earned!
Doorbell
- Live gateway: https://gw-2ad0-3000.prg1.zerops.app
- Source: https://github.com/BigAchiever/doorbell
- Four minute demo: https://www.youtube.com/watch?v=KIYEptODPeo
The README has a thirty second live verification you can run without installing or signing up for anything. Send a webhook to an offline tunnel, get 202 instead of 502, and watch the counter go up.
The Zerops Challenge
- Hackathon page: https://www.wemakedevs.org/hackathons/zerops
- Kickoff livestream with Kunal Kushwaha and Francesco Ciulla: https://www.youtube.com/live/1bpt0iuXuNM
Connect with Danish
- X: https://x.com/big_achieverx
- GitHub: https://github.com/BigAchiever
- LinkedIn: https://www.linkedin.com/in/big-achiever
More winner interviews from The Zerops Challenge are on the way.
Top comments (0)