From Replit to a ¥935/month VPS

AI

Environment: Ubuntu 24.04 on a 1 GB VPS, systemd, nginx, Let’s Encrypt, Postgres. Two apps moved — one Django, one Node. Migrated August 2026.

We run a small app that manages an English school’s teachers, classes and student records, plus a second app for exam practice. Both lived on a hosted development platform at about $20 a month. They now live on a 1 GB VPS at ¥935 a month, which is roughly a third of the cost.

Cost is why it started. It is not why it was worth doing, and it is not the interesting part.

Diagram: three candidate databases before the move — a stale committed sqlite file, an unused managed-Postgres URL in the secrets, and the real Postgres running inside the platform container — next to the stack after the move, and four things that broke
Before: three things that looked like the database. After: an ordinary box. Below: everything that broke, none of which was the application’s own logic.

First, find out where the data actually is

Before planning anything, find the production database. This sounds trivial. It was not.

Three things in that project looked like the database:

  1. A db.sqlite3 committed to the repository. Last written more than a year earlier — a leftover from early development, not production.
  2. A managed-Postgres connection URL sitting in the platform’s secrets. Read by nothing in the codebase. It had presumably been set up once and abandoned. Migrating it would have been migrating an empty database.
  3. A Postgres instance running inside the platform’s own container, whose hostname in DATABASE_URL resolves only inside that platform.

The third one had the data. All of it. Every student record the school had.

And because that hostname is internal, there is no connection from outside — no pg_dump from a laptop, no replica, no external backup, nothing. The entire dataset existed on one disk belonging to a service we were about to stop paying for.

So the first action was not planning. It was a dump, pulled out and stored locally, before touching anything else. Half a megabyte of custom-format Postgres dump — that was the whole business.

If you are on a platform like this, do that today, whether or not you are moving. Then grep the codebase for each environment variable that looks like a database and find out which one is real.

Getting files off a platform that doesn’t want to give them to you

A footnote with a wider point. The platform’s newer UI has no "download as zip" and no right-click-download in the file tree. Support answers involve their CLI or git.

What worked: the app was running with debug on and a static directory configured, so we put the file in static/, fetched it over HTTPS with an unguessable filename, and deleted it immediately.

That is a hack, and it works because the production configuration was wrong in exactly the way that made it possible. Which brings us to the next part.

The move is when you discover you never configured production

The app had been running with the development server, debug enabled, the secret key committed to the repository, and a permissive allowed-hosts list. It worked. Nobody had ever been forced to say out loud "this is production", because the platform made deploying feel like continuing to develop.

Moving forced the sentence. The list of things fixed because of the move, none of which the move required:

  • Debug off, real WSGI server instead of the development server
  • Secret key out of the repository and into the environment
  • Explicit allowed-hosts and trusted-origins for the new domain
  • A nightly database dump, fourteen generations kept, which had not existed at all
  • Two firewalls: the provider’s packet filter and one on the host, 22/80/443 only

That is the actual return on this project. The bill went down; the setup went from "works" to "administered".

Four things that broke

None of them were in the application’s own logic. All four were things the platform had been quietly providing.

A driver bound to one vendor. The Node app connected through a serverless Postgres driver, the kind that reaches the database over the vendor’s own transport rather than the Postgres wire protocol. Pointed at an ordinary Postgres server it does not fail with a connection error — it tries to open a WebSocket to that hostname, meets nginx, and an API route returns 500 with nothing useful in it. Swapping to the standard client fixed it. Check your driver before you check your credentials.

A secret with a fallback. The session key was read as process.env.REPL_ID || "some-literal-in-the-source". On the new host that variable does not exist, so it silently used the literal — a fixed, published session secret with no error and no warning. Now it comes from a real environment variable and the process refuses to start without one. A fallback for a secret is a bug with a friendly face.

A completion check that matched itself. Small and worth knowing: pgrep -f 'npm install' matches the ssh command string that contains those words, so a script waiting for the install to finish waits forever. If you poll by pattern, make the pattern narrower than the command you typed.

The admin account was not who we assumed. The production superuser was not the username everyone had in their head. Discovering that before rotating credentials was luck. Verify who can actually log in before you change anything about logging in — locking yourself out of the box holding the only copy of your data is the one mistake with no undo.

A couple of non-technical ones too, since they cost real time. The registrar’s DNS change screen adds a ¥1,369 domain-protection product to the confirmation by default; decline it in the popup. And DNS is not always managed where you think: of two domains we moved, one had nameservers pointing at the hosting control panel and the other at the registrar’s DNS console. Check the NS records rather than assuming they match.

What we turned off while we were there

A migration is a rare moment when someone reads every part of a system. Use it.

One feature — an AI-based writing-correction tool — was switched off during the move. The usage log was decisive: of the requests it had ever served, roughly half were our own tests, and no external user had come back a second time. It was generating a metered API bill for nothing. The UI and the API route are both closed; the code and the data are still there, behind a flag, if it ever earns its way back.

Deciding that took ten minutes and only happened because the move made someone list every running thing. That list is worth writing even if you never migrate.

About the old posts on this site

This blog used to be mostly deployment tutorials: putting apps on a cloud provider’s VMs, VPCs, bastion hosts, security groups, static hosting on a git-hosting service. Those posts are from 2021 and 2022, and the instructions in them still broadly work.

They are being retired anyway, and pointed here, because they no longer describe how we work or what this site is for. Not because the cloud is wrong — for a small internal app with a known, flat load, it was more machinery than the problem needed, and the last few years have been a steady move in the other direction: fewer moving parts, a box we administer, ordinary tools we can read.

If you arrived here from one of those posts, this is the honest version of the same subject: what we run now, what it costs, and what broke on the way.


That closes this run of posts. The next set goes back to the production side — what happens between a manuscript and a finished book, and which parts of it a model can be trusted with.

タイトルとURLをコピーしました