<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hüseyin Çınar</title>
    <description>The latest articles on DEV Community by Hüseyin Çınar (@codebycinar).</description>
    <link>https://dev.to/codebycinar</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4035905%2F543bbb76-204a-46fa-9071-4e35726dd0a7.jpg</url>
      <title>DEV Community: Hüseyin Çınar</title>
      <link>https://dev.to/codebycinar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/codebycinar"/>
    <language>en</language>
    <item>
      <title>Migrating a live IoT platform off self-hosted Kubernetes - without a single forced app update</title>
      <dc:creator>Hüseyin Çınar</dc:creator>
      <pubDate>Sun, 19 Jul 2026 02:25:28 +0000</pubDate>
      <link>https://dev.to/codebycinar/migrating-a-live-iot-platform-off-self-hosted-kubernetes-without-a-single-forced-app-update-1dma</link>
      <guid>https://dev.to/codebycinar/migrating-a-live-iot-platform-off-self-hosted-kubernetes-without-a-single-forced-app-update-1dma</guid>
      <description>&lt;p&gt;In January 2024 I took over the backend of WeWALK, a smart cane platform for visually impaired people. The codebase dated back to 2020 and had passed through a few hands before mine - and underneath it was a live production system with real users depending on it.&lt;/p&gt;

&lt;p&gt;27,000 registered users, to be exact - and I want to be honest about that number: it is not impressive. People migrate databases with millions of users and write nothing about it. What made this migration interesting was not how many users there were. It was three other things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The users cannot be interrupted.&lt;/strong&gt; For many of them the app is part of how they get around a city. "Please update to continue" is a minor annoyance in a food delivery app; in an accessibility product it can mean someone's cane stops talking to their phone while they are out somewhere. So one rule was fixed before any planning: nobody gets forced to update. Every client binary in the field keeps working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data never stops arriving.&lt;/strong&gt; These are not users who open an app twice a day. Every active cane sends a 102-byte diagnostic snapshot roughly every ten seconds, flowing into a partitioned time-series table. Multiply by a fleet of devices and you have a write stream that never pauses - including during your migration. There is no maintenance window when the "users" are canes on the street.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data was heterogeneous.&lt;/strong&gt; MongoDB and PostgreSQL on the old cluster, Firestore entering the picture on the new one, plus IAP billing events, a rules-based entitlements engine, and an audit trail doing JSONB before/after diffs. This was not one big table to copy. It was several kinds of data with different consistency needs, all moving at once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Oh, and one more thing: the old cluster was effectively a black box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting from backups
&lt;/h2&gt;

&lt;p&gt;The stack ran on a self-hosted Rancher/Kubernetes cluster. By the time the migration started, working access to that environment was no longer practical - the kind of thing that happens naturally over years of team and vendor transitions, and more common in the industry than anyone likes to admit. What we did have, reliably, was backups.&lt;/p&gt;

&lt;p&gt;So the plan became: reconstruct what the system actually was from its backups, and bring that up on Google Cloud Run, Cloud SQL and Firestore instead of trying to revive the old machines.&lt;/p&gt;

&lt;p&gt;If you have never rebuilt a system you cannot inspect, I recommend it as an educational experience and nothing else. You find out exactly which parts of the configuration were written down and which parts had never made it into documentation anywhere. Every service that came up was a small negotiation between what the backup said, what the code expected, and what production traffic proved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Old endpoints, new cloud
&lt;/h2&gt;

&lt;p&gt;Part of the platform lived on AWS, and the mobile apps talked to WebSocket endpoints there. Those addresses are baked into client binaries in the field. Moving to GCP the normal way means new endpoints, which means a mandatory update - the one thing we had ruled out.&lt;/p&gt;

&lt;p&gt;So the old endpoints simply never died. I built a Lambda-to-Cloud-Run proxy that keeps the existing AWS WebSocket addresses alive and forwards everything to the new infrastructure. The oldest client build in the field talks to the same address it always did; it just has no idea the thing answering it moved. Nobody updated anything. Support tickets from the migration: none that I know of.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most valuable tool was a diff
&lt;/h2&gt;

&lt;p&gt;Later we moved nine services between regions, europe-west3 to europe-west1. The thing that saved us was not clever. Before cutover I diffed the configuration of every service between source and target - every environment variable, every setting, service by service.&lt;/p&gt;

&lt;p&gt;That diff caught four missing environment variables. Four. Any one of them would have been a production incident discovered at the worst possible time. After years of running both Kubernetes clusters and serverless platforms, my honest conclusion is that migrations rarely fail in interesting ways. They fail because an env var didn't come along. Diff first. It is boring and it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving data that will not sit still
&lt;/h2&gt;

&lt;p&gt;The data layer had its own migration, Firestore to PostgreSQL - executed while the telemetry stream kept writing. We did it as a dual-write: write to both stores, keep reading from the old one, and put the read cutover behind an environment flag. When the new store had proven itself under the live write load, we flipped reads. The old path stayed in place as an escape hatch, with graceful degradation if anything went sideways.&lt;/p&gt;

&lt;p&gt;The time-series side needed its own care: continuous device snapshots do not tolerate a "stop the world, copy, restart" approach, which is exactly why the table is partitioned and why the cutover had to be a flag flip rather than a batch job.&lt;/p&gt;

&lt;p&gt;That schema has since grown from 34 tables to about 95, across 174 migrations. The system that had to be rebuilt from its backups in early 2024 is now the boring, observable, redeployable kind of production system I like to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stayed with me
&lt;/h2&gt;

&lt;p&gt;The "no forced updates" constraint felt like a burden and turned out to be a filter. It eliminated every lazy option early, and what survived was genuinely better architecture. And the two least glamorous artifacts of the whole project - a pile of backups and a configuration diff - did more for those users than anything clever I wrote.&lt;/p&gt;

&lt;p&gt;If you are inheriting an undocumented production system: test whether you can rebuild from your backups before you need to, and diff your configuration before every cutover. That is the whole post, really.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>googlecloud</category>
      <category>kubernetes</category>
      <category>containers</category>
    </item>
    <item>
      <title>HTTP finally has a QUERY method. I spent an hour finding out what works today</title>
      <dc:creator>Hüseyin Çınar</dc:creator>
      <pubDate>Sun, 19 Jul 2026 02:22:41 +0000</pubDate>
      <link>https://dev.to/codebycinar/http-finally-has-a-query-method-i-spent-an-hour-finding-out-what-works-today-37bj</link>
      <guid>https://dev.to/codebycinar/http-finally-has-a-query-method-i-spent-an-hour-finding-out-what-works-today-37bj</guid>
      <description>&lt;p&gt;I have built more search endpoints than I can count. Candidate search in a recruitment platform, dealer listing filters with saved searches in a vehicle marketplace, telemetry queries in an IoT dashboard. And every single time, the same uncomfortable choice comes up at design time, and every time there are exactly three bad options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one: GET with a query string.&lt;/strong&gt; Works great until the filter object grows. A saved search with fifteen filters, sort orders and pagination does not belong in a URL. You hit length limits, you fight encoding, and the whole thing ends up in access logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: GET with a body.&lt;/strong&gt; Elasticsearch made this famous, and it has been making people miserable ever since. The old HTTP specs never said what a GET body &lt;em&gt;means&lt;/em&gt;, so clients, proxies and load balancers each picked their own behavior - some drop it, some forward it, some reject the request outright. You cannot build on semantics nobody agreed on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option three: POST /search.&lt;/strong&gt; This is what I have done for years, like almost everyone. It works. It is also a small lie. A search is safe and repeatable - it changes nothing on the server - but POST tells every intermediary the opposite. No shared caching. No automatic retry on a dropped connection, because a proxy has to assume a POST might have side effects. You give up real, useful HTTP behavior just to be allowed to send a body.&lt;/p&gt;

&lt;p&gt;Last month this stopped being a "pick your poison" situation. &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/" rel="noopener noreferrer"&gt;RFC 10008&lt;/a&gt;, published June 2026 after fourteen draft revisions, defines the &lt;strong&gt;QUERY&lt;/strong&gt; method: a request with a body, like POST, but explicitly &lt;strong&gt;safe and idempotent&lt;/strong&gt;, like GET - and with cacheable responses. It is precisely the missing verb for "here is a complex description of what I want; this changes nothing".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;QUERY&lt;/span&gt; &lt;span class="nn"&gt;/vehicles&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"filters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fuel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"battery_health_min"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"price_asc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What actually works today
&lt;/h2&gt;

&lt;p&gt;Reading about a new RFC is one thing. I wanted to know what happens when you actually send a QUERY request in 2026, so I spent an hour finding out. Everything below is a real result from my machine, not from documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node.js 24: works out of the box.&lt;/strong&gt; &lt;code&gt;http.METHODS&lt;/code&gt; includes &lt;code&gt;QUERY&lt;/code&gt; as a first-class method, and a plain &lt;code&gt;http.createServer&lt;/code&gt; receives it like any other request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server logs: { method: 'QUERY', receivedBody: '{"filter":{"status":"active"}}' }&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* req.method === 'QUERY' */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;fetch: works.&lt;/strong&gt; Node's built-in fetch (undici) sends &lt;code&gt;method: 'QUERY'&lt;/code&gt; with a body without complaint. The fetch spec only forbids CONNECT, TRACE and TRACK, so QUERY passes - though I have only verified server-side fetch, not every browser, so treat browser support as "test before you rely on it".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;curl: works&lt;/strong&gt;, since a custom method is just &lt;code&gt;curl -X QUERY --data ...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare: passes it through.&lt;/strong&gt; I sent a QUERY request to this very site, which runs on a Cloudflare Worker serving static assets. The response was a clean &lt;code&gt;405 Method Not Allowed&lt;/code&gt; - meaning the edge did not choke on the method; it delivered it to the handler, and the handler correctly said "static files don't do that". For a brand-new verb, the method traveling untouched through a major CDN is the important part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ASP.NET Core:&lt;/strong&gt; there is no &lt;code&gt;MapQuery()&lt;/code&gt; helper yet, but routing has always supported custom methods, so &lt;code&gt;endpoints.MapMethods("/search", ["QUERY"], handler)&lt;/code&gt; routes it today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I use it in production?
&lt;/h2&gt;

&lt;p&gt;On a public API, not yet. The RFC is a month old; API gateways, WAF rules, OpenAPI tooling and client SDK generators all need time, and a search endpoint that some corporate proxy silently mangles is worse than an honest POST.&lt;/p&gt;

&lt;p&gt;But on internal APIs - service-to-service, admin panels, anything where I control both ends - I intend to start now. The retry semantics alone are worth it: an idempotent search can be safely retried by infrastructure when a connection drops, which is exactly the kind of resilience you otherwise have to build by hand. My plan is to introduce it on an internal admin search endpoint first and let it earn its way outward.&lt;/p&gt;

&lt;p&gt;Twenty-five years of &lt;code&gt;POST /search&lt;/code&gt; was a workaround. It is nice to finally have the real verb.&lt;/p&gt;

</description>
      <category>http</category>
      <category>webdev</category>
      <category>api</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
