← All case studies Heroku → Fly.io

Tanin moved a Swift Vapor app off Heroku and left Redis behind

After Heroku killed the free tier, Tanin Rojanapiansatith copied a Vapor app to Fly.io, rebuilt fly.toml, and only later replaced the Heroku Redis cache.

Tanin Rojanapiansatith published Migrating from Heroku to Fly.io (Swift Vapor) on 13 February 2023. This is his own post, not a vendor page.

The app was Curry, a small iOS currency converter. The iOS client called a Swift Vapor server. That server called paid currency APIs and cached the rate, so the phone did not burn API quota. Heroku hosted the server and a Redis add-on.

Heroku ended free dynos, Postgres, and Redis in late 2022. Free plans stopped on 28 November 2022. Tanin’s bill became about £12 a month for a side project with no revenue. He thought about shutting Curry down. The bill is what pushed him to move.

If you are looking at the same route, start with the Heroku → Fly.io playbook. Tanin’s post is the hobby-app field report, including a partial cutover where Redis stayed on Heroku. Fly’s current migrate-from-Heroku guide is the CLI path.

Why Fly.io, not AWS

He kept telling himself the next home was AWS. That idea sat on a Google Jam board and blocked the work.

In December 2022 he went to Server-Side Swift Conference. Mikaela gave a talk on hosting a server-side Swift app on Fly.io and called it a Heroku alternative. Other talks covered AWS. He tried Fly first.

Fly had a “Copy from Heroku” launcher. Turboku is the Fly write-up for that button. You authenticated, picked a Heroku app, and Fly cloned the slug, the process map, and the config vars. Databases and Redis add-ons stayed where they were. The Heroku app stayed up.

He filled the form in a few minutes. The new Fly app answered a currency-rate query. It did not always work, because Redis still lived on Heroku. The cloned app could reach that Heroku Redis URL, which impressed him, and also proved the copy was incomplete.

He treated Turboku as a compatibility check, then started a clean Fly app.

How he moved

The copy had given him workers and some environment variables. It had not given him a real fly.toml, and it had not moved Redis.

He installed flyctl and ran flyctl launch in the Vapor repo. The project already had a Dockerfile. Fly picked that up and wrote a fly.toml.

Heroku’s process list lived in a Procfile. His two process types were a web process and a scheduled worker. On Fly those become a [processes] block. He had to edit the generated file after the first deploy errors.

The Procfile used Run and $PORT. Fly already prefixed Run, so he dropped that word. He also replaced $PORT with 8080. He never found where Heroku had set PORT, so he hardcoded the port Fly expected. Because he named the process web, he pointed the HTTP service at that name instead of the generated app.

[processes]
  web = "serve --env production --hostname 0.0.0.0 --port 8080"
  scheduledworker = "queues --scheduled"

Secrets were a second pass. A new Fly app does not inherit Heroku config vars. He set them with flyctl secrets set, or in the dashboard after the first launch deploy. While writing the post he found Fly’s pipe:

heroku config -s | grep -v -e "DATABASE_URL" -e "REDIS_URL" -e "REDIS_TLS_URL" | fly secrets import

That is the same idea Fly still documents: export Heroku config, drop the add-on URLs, import the rest. He notes that every new secret creates a new release.

Redis was the add-on that actually held state. He created a Redis instance on Fly, took the new URL, and swapped it for the Heroku one. Fly now provisions that with fly redis create, which stands up Upstash Redis. The current migrate guide is blunt: Fly Redis does not import existing keys. For a currency-rate cache that is fine. The cache warms on the next fetch.

He does not describe a Postgres database. Curry’s durable state was the Redis cache plus the iOS clients.

The iOS cutover

The server URL was hardcoded in the iOS app. Changing Redis or the host was not a DNS flip. Users had to install a new build.

He shipped that build with App Store phased release. Automatic updates roll out over seven days: 1 percent, then 2, 5, 10, 20, 50, then everyone. Anyone can still grab the new version by hand. He already used phased release for risky updates. Here it was how he watched Fly take traffic.

He thought about putting the server URL in a text file on S3 and letting the app read it. He did not. This was a side project.

The last day of the phased release was 6 January 2023. That was also the day the Heroku bill arrived. After the rollout finished, he left Heroku running for three more days. When the Heroku traffic graph went quiet, he shut the dynos down.

What he gained

The Fly bill was zero. He had probably under 100 active users a day. That is his number, on his hobby traffic, in early 2023. It is not a Fly price list.

The £12 a month Heroku bill went away once the dynos and the Redis add-on stopped. He does not publish the Fly itemized invoice, so I am not inventing one.

He also got a host he was willing to use for the next hobby project. The Vapor Dockerfile deployed. The scheduled worker had a place in fly.toml.

He still says Heroku is a good host when you want the add-on marketplace.

What broke or got skipped

The magic button did not finish the job. Procfile to fly.toml was manual. Redis stayed on Heroku until he created a new instance. While that lasted, the Fly app still called a Heroku add-on URL. The clone proved Vapor would run. Redis still had to move.

The hardcoded client URL made the cutover an App Store release. Old app versions kept hitting Heroku until people updated, or until he turned Heroku off. He waited for zero traffic. Anyone who never updated would have lost the proxy after those three days. He does not describe support tickets from that.

He does not describe a failed Fly deploy after the Run and $PORT fixes, a Redis data copy, or a rollback to Heroku. Currency rates are a cache. Losing the old keys was acceptable.

While writing, he noticed Heroku Eco: $5 a month for a shared 1000-hour pool, with sleep after 30 minutes idle. He did not move back. He also linked Danny Postma’s tweet about a deleted Heroku account and a move to Render. If that is your next hop, the matching page here is the Heroku → Render playbook.

What I would copy

Use the clone, or fly launch, as a probe. Hit one real endpoint. Then inventory add-ons. If Redis or Postgres still have Heroku URLs, the app is not on Fly yet.

Rebuild fly.toml from the Procfile instead of trusting the generated file. Web and worker commands change. $PORT is the one that bit him.

Import secrets with the pipe, and exclude DATABASE_URL and REDIS_URL so you do not keep pointing at Heroku by accident.

If a mobile app hardcodes the host, the hosting move is an app release. Phased release plus a few extra days on the old host is how he drained traffic. A remote URL file would have been faster. He did not have one.

For a cache, skip the Redis export. For anything you cannot rebuild, copy the data before you cut the old URL.

Run the Heroku → Fly.io playbook. Use Tanin’s post for the Vapor-plus-Redis, component-by-component path.