What happened

If you tried to buy a Snowpad plan in the last week and the checkout hung or returned an error, this post is for you. We are doing infrastructure surgery on the dashboard, and the surgical window is the part that has been failing. The proxy network itself is fine — gateway, mobile nodes, sticky sessions, SOCKS5 connections, all running. It is the front door that has been wobbling.

The wobble is a class of issue that I have written about before. The Snowpad web dashboard runs on Vercel, reads and writes from a Postgres database, and renders the billing page where Razorpay checkout opens. That Postgres database is hosted on Neon, a serverless Postgres provider that runs on AWS in us-east-1 and serves queries over HTTPS through their HTTP proxy. The Razorpay checkout modal, the billing page that opens it, the subscription.activated webhook that confirms payment — all of these touch the same database on the hot path.

For the first eight months of Snowpad's life, that worked. Then we hit a scale where it started failing in ways that were not our bug.

The edge tax

Neon's serverless Postgres has a really nice property: there is no database server to manage. Compute spins up on demand, scales to zero when idle, and wakes in a few hundred milliseconds on the first query after idle. The price we pay for that is that "a few hundred milliseconds" is the floor. Sometimes it is 800ms. Sometimes it is 2 seconds. And sometimes, when the wake-up path intersects with a Razorpay payment confirmation, it is longer than that — long enough that Razorpay's own client decides the request timed out, the modal closes with a generic error, and the customer sees a page that says "Payment failed" even though the money was captured on Razorpay's side.

This is not a Neon bug. It is what "serverless" means: the database does not exist between requests, so the first request after idle pays the wake-up cost. The first time I watched this happen on a Grafana dashboard, I saw 11 distinct billing-related timeouts in a 90-second window, all with the same fingerprint: the query that would normally take 12ms took 1.4s, the Razorpay modal closed before the success handler could fire, and the customer got an email from Razorpay saying their payment was captured but our dashboard showed no plan. The fix in each case was a manual reconciliation: I would log into Razorpay, find the captured payment, run a server-side orders.fetchPayments lookup, and provision the customer by hand. We shipped automatic reconciliation in the verify route (the PR #630 follow-up that added the safety-net), but the manual fallback was the actual fix that kept me sane.

The deeper issue is architectural. The web dashboard, the gateway, and the proxy pool all live on the same Mumbai VPS. The gateway has direct, sub-millisecond access to its own SQLite database, which is the source of truth for proxy state, client limits, and the online node list. The web dashboard, by contrast, is on Vercel — geographically distant from the gateway, depending on a serverless Postgres in us-east-1 for every plan read, every subscription write, every Razorpay webhook verification. Every read is a network hop. Every write is two network hops. And every cold start pays the wake-up tax.

What we are changing

There are two pieces of work, and they are independent.

The first piece is moving the dashboard's data to a self-hosted Postgres 18 with TimescaleDB on the same Mumbai VPS the gateway already runs on. The schema is already there. PR #680 shipped a 15-table internal/gateway/pgschema.go that mirrors every SQLite table — operators, nodes, clients, hardware, endpoints, port_counters, freed_ports, all of it — with Postgres-native types (TIMESTAMPTZ, JSONB, NUMERIC(12,6), BIGINT GENERATED ALWAYS AS IDENTITY). The peer-connection-postgres container has been running on the VPS for months as a secondary log sink; the gateway already dual-writes connection_logs there. The work that remains is moving the plans, subscriptions, and payments tables from Neon to the same Postgres, switching the dashboard's reads and writes to it, and validating the cutover row-by-row before flipping the live traffic.

The second piece is splitting the marketing site from the product. The dashboard will move from snowpad.io/dashboard to console.snowpad.io. The marketing site (snowpad.io, www.snowpad.io) keeps the landing page, the blog, the pricing overview, and the sign-up form. The product (console.snowpad.io) is where you log in, view your plan, manage API keys, and open the proxy pool. This is a real split — different cookie domain, different deploy pipeline, different release cadence — not a cosmetic URL change. The reason it matters: today, if the dashboard's database is slow, the marketing site is also slow. They share an origin, a build, a deployment. After the split, a Postgres slowdown in the product cannot affect the marketing site that brings in new sign-ups.

The pricing lock you see right now is the cutover window. While the new Postgres is being warmed up, the Razorpay keys are being rotated, and the webhook handler is being re-pointed, paid checkout is paused. Existing subscriptions keep auto-renewing on Razorpay's side — the web dashboard does not sit in the renewal path. New paid sign-ups, upgrades, and add-on purchases are gated to a "contact deepesh@snowpad.io" mailto until the cutover is verified. Free sign-ups keep working because they never touch Razorpay — the free_provision path provisions server-side.

The one-DB tradeoff

The decision to consolidate onto one database is deliberate, and it is not a free lunch. Let me be specific about what we are gaining and what we are giving up.

What we are gaining. The biggest win is latency. The current dashboard reads a plans row over HTTPS from us-east-1 on every render. After the cutover, that same read crosses localhost on the same VPS — sub-millisecond. Aggregations that take 420ms in SQLite today take 158ms in Postgres on the same hardware, because the query planner is smarter and the indexes are wider (pg_stat_statements is doing work ANALYZE could not). The dashboard's P95 page-load will drop by a factor I cannot pin down without re-running the benchmark, but it will drop. The second win is operational coherence: there is one database, one backup chain (nightly pg_dump to /opt/peer-connection/backups/pg/), one restore drill, one connection pool to size, one set of credentials to rotate. The third win is cost: Neon is not free, and the cost grows linearly with the size of connection_logs once it lives there. Self-hosting on a VPS we already pay for is fixed-cost.

What we are giving up. The biggest loss is serverless autoscale. Neon scales compute up and down automatically. Self-hosted Postgres on a single VPS does not scale at all — it is one machine, and if that machine is unhealthy, the dashboard is unhealthy. The mitigation is HA (a hot standby on the same VPS, with streaming replication, plus the existing SQLite as a read-only fallback for the gateway hot path), but that mitigation has a different cost structure than Neon's "spin up another compute." The second loss is zero-management. Neon handles vacuuming, point-in-time recovery, connection pooling, and version upgrades as a managed service. Self-hosted means we handle all of that — or accept the consequences. The third loss is multi-region. Neon can serve a read replica in any region in seconds. A self-hosted Postgres on a Mumbai VPS is in Mumbai. If we ever want a Singapore or Frankfurt read replica, we are back to operating another machine. None of these losses are dealbreakers for Snowpad's current scale, but they are real and I would be lying if I said otherwise.

What changes for you

For free users: nothing. Sign up at snowpad.io/sign-up, get your 5GB, use it.

For paid users: nothing. Your plan keeps running, your auto-renewal keeps charging, your proxy pool keeps working. If you need to upgrade or add a node in the next week or so, email me directly and I will set it up by hand.

For new paid sign-ups: the /pricing page and the homepage pricing section will show "Temporarily unavailable" on the Pro and Dedicated add-on CTAs, with a link to email me. The Free CTA on both pages still works.

For everyone using the blog or the marketing site: nothing. The split is between marketing (snowpad.io) and product (console.snowpad.io). Reading the sticky sessions guide, checking the benchmarks, browsing the FAQ — all unchanged.

What happens next

The cutover plan, in order:

  1. Stand up the new console.snowpad.io subdomain on Vercel, pointed at the same Next.js build.
  2. Run a one-time pg_dump of the live Neon plans, subscriptions, and payments tables, restore into the self-hosted Postgres, validate row counts against the live Neon counts, then freeze Neon reads.
  3. Flip the dashboard's DATABASE_URL to point at the self-hosted instance behind a feature flag.
  4. Re-verify the Razorpay webhook end-to-end on the new path: create a test subscription, confirm subscription.activated fires the dashboard provision, confirm the subscription row appears in the new Postgres, confirm the customer can see their plan in the dashboard.
  5. Open the pricing lock — flip NEXT_PUBLIC_PRICING_LOCKED=false, restore the Pro and add-on CTAs to their normal /sign-up flow.
  6. Add a 301 redirect from snowpad.io/dashboard/* to console.snowpad.io/dashboard/* so existing bookmarks and emails keep working.

The whole thing is a one-week window. The pricing lock is the only externally visible part. Everything else is plumbing.

If you have any questions, email deepesh@snowpad.io. If you want a more detailed engineering writeup of the schema, the dual-write loop, the TimescaleDB hypertable conversion, or the cutover scripts, comment on the GitHub issue and I will publish the runbooks.