THAIPO.ai
Back to blog

Six apps down to three: retiring features without deleting them

10 November 2026 · ThaiPo

One day in August we destroyed half of our production estate on purpose. Three apps, their volumes, their dedicated IP addresses, their certificates, their DNS records. The saving was about nine dollars a month, and that was not the reason.

The reason was surface area. ThaiPo is built and run by one developer, and it had grown to six deployed apps: the web app, the serving plane that translates, the learning plane that does the slow background work, plus a business console, a WhatsApp connector and a LINE OpenChat connector. The last three were real, working products. They were also three more things that could break at 3am, and one of them needed feeding every week.

Six server boxes in a row: three are lit and running, three are neatly packed into labelled storage crates with their power cables coiled on top

What the extra three actually cost

Not money. Attention.

  • A weekly chore. The OpenChat connector held a logged-in session that died roughly seven days after each QR scan, no matter how healthy the machine was. The client library refreshed its token but never wrote the rotated one back to storage, so the session could not outlive the scan that created it. Every week: pick up a specific phone, scan a code, check the logs. We had a scheduled reminder for it. The reminder could not fix anything. It could only remind.
  • Failure modes with good manners.Scan that QR with the wrong phone and everything succeeded. A valid-looking token was written and the health check said healthy. Once, a crash loop used up the host's restart budget and left the machine stopped; setting the new credential reported success and started nothing, for about nine quiet minutes.
  • A deploy that could not deploy.One release failed because the connector's volume sat on an unreachable host. The connector itself was fine, still serving on the previous image. The obvious fix, recreating the volume somewhere healthy, was the one action that would have caused an outage, because the volume was where the session lived.
  • A rule you had to remember. That connector had to run as exactly one machine. Two would mean two copies of the same session and every translation posted twice.

None of this is dramatic. It is just a list of things one person has to carry in their head.

Delete the infrastructure, keep the code

The usual way to retire a feature is to delete it and feel clean. We did half of that: the infrastructure is gone and the code is all still in the tree. The connectors, the console and its 45 API routes, the translator's per-channel forks. Deleting machines is cheap to reverse. Rewriting a WhatsApp integration from memory is not.

The catch is that "the code is still there but it is off" is exactly the sentence people say shortly before an incident. So the interesting work was making off a property you can check, in several independent places, instead of a hope.

Switch one: a database row that outranks the environment

Our feature flags live in a Postgres table and are flipped from the admin console, with no redeploy and no restart. Resolution is simple: a database row if one exists, otherwise the environment variable. For the two connectors, both Go services treat the row as the winner.

That ordering is the whole point. Our runtime secrets are scattered across apps and are write-only: you can set one, never read it back. One row, in one table, that you can SELECT, is a switch you can audit. Is WhatsApp off? Read the row. It says false.

Switch two: make the inbound door fail to match

The business console let a company connect its own LINE account, and each connected account was looked up by a routing key on the way in. We wanted those accounts unable to deliver a single webhook to us. LINE has no API for the "use webhook" toggle; it is a checkbox in a console we do not own. So we could not turn it off on their side. We made our side unable to say yes.

Every stored routing key got a four-character prefix. A prefixed key fails validation at ingest, no inbound webhook can ever match one, and the request is rejected at signature verification. We chose a prefix over setting the column to null for a boring reason: the original id survives inside the row. The encrypted credentials next to it were not touched at all. Restoring every account is one statement (names changed here):

UPDATE connected_accounts SET routing_key = substring(routing_key from 5) WHERE routing_key LIKE 'off:%';

Switch three: a deploy script that knows what does not exist

We deploy through one script that works out which apps a change affects and ships only those. The serving and learning planes share packages with the retired connectors, so a one-line change to shared code used to mean "deploy four things". After the retirement it would have meant "try to deploy two apps that are not there".

The script now carries a short dormant list. Change detection skips those apps entirely, so nothing can ship one by accident. The routing rules for them are still in place, and naming one explicitly still deploys it, with a line reminding you that the app must exist and hold its secrets first. Never detected, always available. That is the behaviour you want from something in storage.

Switch four: the code that still ships

The business console is built from the same image as the main web app, so its pages and routes are physically present in production right now. They are inert. Everything in it that acts for a signed-in user asks a single gate whether this app is the business app, and the gate answers from the server's own configured origin. Never from a request header. A header is something a stranger gets to type; configuration is something we set. A creative Host value changes nothing, and on the consumer site the console's path redirects away before any of it renders.

The one thing we could not delete

Those encrypted credentials are still rows in the database, and one encryption key decrypts them. Every other secret belonging to the retired apps went with them. That key stayed, with a note saying why. Lose it and the retirement stops being reversible. A cleanup that helpfully removes "unused" secrets is the most realistic way this whole plan fails, so it is written down in the repo's conventions file, the first thing anyone working on it reads.

Leave the resurrection where the corpse is

Each retired app's config file is still in the repo, and each now opens with a banner: DORMANT, destroyed on this date, nothing deploys this file. Then the revival recipe, five or six numbered steps: recreate the app, restore the secrets, re-point the DNS, undo the specific off switch, deploy by name. The OpenChat one includes "scan a fresh QR", because of course it does.

The banners also say why the file was kept. One records that the third-party wiring is "expensive to rediscover". Another exists mostly to preserve the single-machine rule. And each ends with the line that matters most to a tired reader: treat everything below as a description of how this app was wired, never as a thing to run.

A wiki page would have drifted. The recipe sits in the file you would have to open anyway.

What we took from it

  • Delete infrastructure, keep code, document the resurrection. Machines are the expensive part to keep and the cheap part to recreate. Code is the reverse.
  • A kill switch you cannot verify is a wish. Ours are a row you can read, a key that cannot match, a list in a script and an origin check. Each can be checked on its own.
  • Fewer moving parts is a feature. For a team of one it may be the feature. Three apps, five machines, and nobody scans a QR code on Thursdays.

ThaiPo is designed, built and run by one developer, Steven Acreman.

Try it in your own chats

ThaiPo lives inside LINE and translates every message in both directions, free forever. Get started free or add @thaipo.ai as a friend on LINE and it sends your signup link right in the chat.

ThaiPo is operated by Marine Entertainment Co., Ltd. · Docs · Blog · Privacy · Terms · Contact