Getting started / Porting from Heroku /
Steps to porting an app

Steps to porting an app

This is the order that works for most apps. Each step links to the detailed page. Expect the whole thing to take an afternoon for a typical Rails/Django/Node app with a Postgres database; most of the elapsed time is AWS provisioning and copying data.

1. Inventory the Heroku app

Capture everything you will need to recreate:

heroku apps:info -a heroku-app
heroku ps -a heroku-app                 # process types and dyno sizes
cat Procfile
heroku config -a heroku-app > heroku.env
heroku addons -a heroku-app
heroku domains -a heroku-app
heroku certs -a heroku-app
heroku pg:info -a heroku-app

Also note: Scheduler jobs (Heroku Scheduler dashboard), buildpacks in use (heroku buildpacks), the stack, any app.json for review apps, and log drains (heroku drains).

Go through the add-ons list against the Add-ons page and decide what each becomes. Anything in the “without a Tapitalee equivalent” section needs a plan before you start.

2. Install the CLI, create a team, connect AWS

  • Install tapit and run tapit ping to log in.
  • Create a team at app.tapitalee.com and connect it to your AWS account, following the on-screen steps. Pick a plan.
  • tapit list teams should now show the team. Set export TAPIT_TEAM=myteam to avoid typing -t on every command.

3. Create the app

Choose the AWS region closest to your Heroku region (us-east-1 for Heroku US, eu-west-1 for Heroku EU).

tapit create app myapp region=us-east-1 cpu=0.5 memory=1 demand=1 --wait
export TAPIT_APP=myapp
tapit show app

The first app in a region also creates the VPC, so --wait can take a few minutes. See Apps and Processes for choosing cpu/memory.

4. Create the add-ons

Create data stores first so their variables exist before the first deploy. See Databases.

tapit create rds engine=postgres name=maindb version=16 size=db.t4g.small storage=50
tapit create elasticache engine=redis name=redis cache_eviction=no
tapit create s3 name=uploads
tapit list addons --wait

Add a TLS proxy now too, unless you will use Cloudflare:

tapit create secureproxy acme_email=ops@example.com https_redirect=true

5. Import config vars

Edit heroku.env from step 1: remove DATABASE_URL, REDIS_URL and any other add-on-supplied variables (Tapitalee manages those), and remove PORT. Then:

# Non-secret settings
tapit import variables < heroku.env

# Or mark everything secret (API keys, SECRET_KEY_BASE, etc.)
tapit import variables secret=yes < heroku-secrets.env

tapit list variables

Each set variable triggers a redeploy, so importing in bulk before the first deploy is the fast path. Rename anything your code expects under a Heroku-specific name (REDISCLOUD_URL, BUCKETEER_*, HEROKU_APP_NAME) or use variable= on the add-on. See Variables and System Variables for the TAP_* replacements for Dyno Metadata.

6. Prepare the build

Decide between buildpacks and a Dockerfile (Build Process):

  • Buildpacks: keep your Procfile. Heroku’s classic buildpacks are not used; Tapitalee runs Cloud Native Buildpacks with pack, which handles Ruby, Node, Python, Go, Java, PHP and more. Custom Heroku buildpacks (heroku buildpacks:add ...) usually have a CNB equivalent, or move to a Dockerfile. Language notes: Ruby / Rails, Elixir.
  • Dockerfile: if you already deployed to Heroku with heroku container:push, your Dockerfile works as-is. Make sure the image listens on $PORT and, for tapit run bash, includes bash and curl.

Check .slugignore content moves to .dockerignore (Dockerfile builds) or project.toml excludes (buildpacks).

7. First deploy

tapit image deploy --wait
tapit show deploy
tapit show logs

tapit image deploy builds the image locally with Docker, pushes it to the app’s ECR registry and deploys. See Deploying from the CLI. The app is now reachable on its *.dns.tapitalee.net hostname (tapit show app), with an empty database.

If the container fails health checks, tapit show events and tapit show logs explain why; the deploy rolls back automatically.

8. Recreate processes, release phase and scheduler

From the Procfile and Scheduler inventory (Processes):

# worker: bundle exec sidekiq
tapit create process name=worker command="bundle exec sidekiq" cpu=0.5 memory=1 demand_count=1

# release: rails db:migrate
tapit create command name=migrate 'bundle exec rails db:migrate'
tapit create predeploy_step name=migrate

# Scheduler: rake cleanup daily
tapit create command name=cleanup 'bundle exec rake cleanup' schedule='0 2 * * *'

Keep worker disabled (disabled=true) until the data is migrated if it would act on an empty database.

9. Migrate the data

Follow Migrating your data: maintenance mode on Heroku, pg:backups:capture, pg:backups:download, pg_restore into RDS. Then run migrations if needed:

tapit create task 'bundle exec rails db:migrate' --wait

Copy files from Bucketeer or any other S3 bucket into the new bucket with aws s3 sync using the credentials from each side (tapit show addon:credentials name=uploads for external access if you enabled external_access=true).

The utility image

The simplest place to do all of this is a shell inside your VPC, where the new RDS and ElastiCache add-ons are already reachable and their DATABASE_URL / REDIS_URL variables are set. Click Utility image on the app’s Console & Tasks page, or:

tapit run bash image=utilitycontainer

It is an Ubuntu container preloaded with database and S3 tooling: pg_dump / psql / pg_restore, mysqldump / mysql / mysqlsh, redis-cli, redisync, dbconsole, dbtop, the AWS CLI, s5cmd. Your Tapitalee add-ons are accessible without any setup; Heroku add-ons are accessible too by pasting in their URL from heroku config, since they are open to the internet. If you need really long-running jobs, its better to use an EC2 instance add-on.

Postgres: dump from Heroku and restore into RDS in one stream, with no file to store:

HEROKU_URL='postgres://user:pass@ec2-....compute-1.amazonaws.com:5432/dbname'   # from heroku config
pg_dump --no-acl --no-owner -Fc "$HEROKU_URL" | pg_restore --clean --if-exists --no-acl --no-owner -d "$DATABASE_URL"

Or in two steps, if you want to keep a copy: pg_dump -Fc "$HEROKU_URL" > heroku.dump then pg_restore --clean --if-exists --no-acl --no-owner -d "$DATABASE_URL" heroku.dump. Check with psql "$DATABASE_URL" -c '\dt+', or dbconsole "$DATABASE_URL" for an interactive session.

MySQL (JawsDB, ClearDB): the same pattern with mysqldump and mysql. The classic clients do not accept a URL, so split it into parts:

mysqldump --single-transaction --set-gtid-purged=OFF -h heroku-host -u heroku-user -pheroku-pass heroku-db \
  | mysql -h rds-host -u rds-user -prds-pass rds-db

The RDS values are in $DATABASE_URL (mysql://user:pass@host:3306/db), or via tapit show addon:credentials name=maindb.

Redis (Heroku Redis, RedisCloud, Upstash): redisync copies every key, with TTLs, from one Redis to another using DUMP/RESTORE, and works with both redis:// and rediss:// URLs. Useful for Sidekiq/Resque queues and sessions; plain caches can be skipped.

redisync -from 'rediss://:pass@ec2-....compute-1.amazonaws.com:6379' -to "$REDIS_URL"
redis-cli -u "$REDIS_URL" dbsize

Add -workers 50 for a large keyspace. Existing keys at the destination are replaced. Heroku Redis rediss:// URLs use self-signed certificates; if the TLS connection is refused, use the plain redis:// URL Heroku also provides.

S3 (Bucketeer, or your own buckets): the app’s S3 add-ons are already authorised via the task role, so only the source side needs credentials. s5cmd is much faster than aws s3 sync for buckets with many objects:

# Source bucket credentials from heroku config (BUCKETEER_AWS_ACCESS_KEY_ID etc.)
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_REGION=us-east-1
aws s3 sync s3://bucketeer-bucket /tmp/uploads
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY      # back to the task role
s5cmd cp /tmp/uploads/ "s3://$S3_BUCKET/"

Copying via local disk avoids needing one set of credentials that can read the old bucket and write the new one; give the task a larger ephemeral disk if the bucket is big, or copy it one prefix at a time. If the Heroku-side credentials are allowed to write to your new bucket (add a bucket policy for that IAM user), a direct aws s3 sync s3://bucketeer-bucket "s3://$S3_BUCKET" works with no local copy.

Anything you can do interactively can also run unattended as a task, e.g. tapit create task 'redisync -from ... -to $REDIS_URL' image=utilitycontainer --wait, and reused as a scheduled tapit create command if you want to keep the two sides in sync until cutover.

Rehearse the copy first

Copying data is the slowest step of the whole port. A few GB moves in minutes, but a large database can take several hours or more, bounded by Heroku’s outbound bandwidth and RDS write throughput, and that whole time is downtime if you do it during the maintenance window.

Do a full trial run ahead of time, before announcing a cutover:

  • Run the exact pg_dump | pg_restore (or mysqldump, redisync, S3 sync) commands above against the live Heroku data while Heroku is still serving traffic. The result is a stale copy you will throw away, but you learn the real duration and catch errors: missing extensions, roles or ownership problems, --no-acl/--no-owner omissions, and app code that breaks against RDS.
  • Time it (time pg_dump ... | pg_restore ...) and size the maintenance window from that, with margin.
  • If the trial is too slow, options are: a larger RDS instance class and more provisioned IOPS for the import (downsize afterwards with tapit set rds), dumping to S3 first with pigz compression and restoring with pg_restore -j 4 from the file, or doing the bulk of the S3 object copy in advance and only syncing the delta at cutover. Redis via redisync is incremental, so it can be run repeatedly with only the final run inside the window.
  • Drop the trial data before the real import (pg_restore --clean --if-exists handles this for Postgres) so the real run is not slowed down by conflicts.

Once the timing is known, the real cutover is: heroku maintenance:on, run the rehearsed commands, verify, switch DNS.

10. Test on the Tapitalee hostname

Smoke test against the *.dns.tapitalee.net URL or by temporarily adding a test domain. Open a console for spot checks:

tapit run 'bundle exec rails console'
tapit show logs -f

Enable the worker: tapit set process name=worker disabled=false.

11. Domains, TLS and DNS cutover

tapit create domain name=www.example.com
tapit create domain name=example.com
tapit list domains

Then choose one of (Webserving, Domains):

  • DNS elsewhere: CNAME www.example.com to the app’s internal hostname. The bare domain cannot be a CNAME; use your DNS provider’s ALIAS/ANAME, redirect it to www, or move DNS to Route53.
  • Route53: tapit create route53_zone domain_name=example.com finds or creates the hosted zone and keeps A records for your domains current after every deploy. Point the registrar at the zone’s nameservers.
  • Cloudflare: tapit create cloudflare tunnel_token=... and configure the tunnel’s public hostnames in Cloudflare; no CNAME to Tapitalee required.

SecureProxy requests Let’s Encrypt certificates once DNS resolves to it (equivalent to Heroku ACM). Lower the DNS TTL on the Heroku records a day ahead, then switch.

12. Set up CI/CD

Replace the Heroku Git remote or GitHub integration:

tapit create deploy_token description='GitHub Actions'

Store it as TAPIT_TOKEN and use the GitHub Actions workflows, or call tapit image deploy from any CI. Review apps become Preview apps with the preview app workflow.

Team members: invite them under the team’s Memberships with the right role.

13. Decommission Heroku

Once traffic has moved and you have a verified RDS snapshot (tapit create snapshot addon=maindb name=post-migration):

heroku maintenance:on -a heroku-app
heroku ps:scale web=0 worker=0 -a heroku-app
# after a safe period
heroku apps:destroy -a heroku-app

Keep a final heroku pg:backups:download archive somewhere safe before destroying the app.

Quick reference

Step heroku tapit
Create app heroku create myapp --region us tapit create app myapp region=us-east-1
Add Postgres heroku addons:create heroku-postgresql tapit create rds engine=postgres name=maindb
Add Redis heroku addons:create heroku-redis tapit create elasticache engine=redis name=redis
Config heroku config:set A=1 B=2 tapit import variables < file / tapit set variable A 1
Deploy git push heroku main tapit image deploy
Worker Procfile + heroku ps:scale worker=1 tapit create process name=worker command=...
Release phase release: in Procfile tapit create command + tapit create predeploy_step
Scheduler Heroku Scheduler UI tapit create command ... schedule='cron'
Migrate heroku run rails db:migrate tapit create task 'rails db:migrate' --wait
DB shell / data copy heroku pg:psql, heroku redis:cli tapit run bash image=utilitycontainer then dbconsole, pg_dump/pg_restore, redisync
Domain heroku domains:add www.example.com tapit create domain name=www.example.com
TLS heroku certs:auto:enable tapit create secureproxy acme_email=...
CI token heroku authorizations:create tapit create deploy_token description=...
Review apps app.json + pipeline tapit create preview pr-123 delete_in_days=7
Logs heroku logs --tail tapit show logs -f