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
tapitand runtapit pingto 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 teamsshould now show the team. Setexport TAPIT_TEAM=myteamto avoid typing-ton 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 withpack, 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$PORTand, fortapit run bash, includesbashandcurl.
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(ormysqldump,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-owneromissions, 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 withpigzcompression and restoring withpg_restore -j 4from the file, or doing the bulk of the S3 object copy in advance and only syncing the delta at cutover. Redis viaredisyncis 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-existshandles 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.comto the app’s internal hostname. The bare domain cannot be a CNAME; use your DNS provider’s ALIAS/ANAME, redirect it towww, or move DNS to Route53. - Route53:
tapit create route53_zone domain_name=example.comfinds 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 |