Migrate from Heroku to Arxentra: Procfile to Docker, Add-ons to Services, DNS Cutover

16 min read
heroku migrationcloud migrationdockerdatabase migrationDNS cutoverheroku alternative

Heroku pioneered platform-as-a-service but has become increasingly expensive since Salesforce's acquisition. Basic dynos start at $7/month with 512 MB RAM, and a production stack with PostgreSQL, Redis, and a worker dyno easily exceeds $200/month. Arxentra provides equivalent functionality at 40-60% lower cost with better performance (dedicated vCPUs instead of shared), more storage, and included monitoring.

This guide walks you through migrating a Heroku application to Arxentra with zero downtime. The migration typically takes 2-4 hours for a standard web application with a PostgreSQL database.

Migration overview

StepDurationDowntime

1. Convert Procfile to Dockerfile15-30 minNone
2. Replace Heroku add-ons10-20 minNone
3. Deploy to Arxentra5-10 minNone
4. Migrate PostgreSQL data30-60 minNone (read-only)
5. Configure environment variables10-15 minNone
6. DNS cutover5-10 min0-5 min (DNS propagation)
7. Verify and decommission Heroku15-30 minNone
Total1.5-3 hours0-5 min

Step 1: Convert Procfile to Dockerfile

Heroku uses Procfile to define process types. Arxentra uses Dockerfiles. Here are conversions for common stacks:

Node.js / Express / Next.js

Heroku Procfile:

web: npm start

worker: npm run worker

Arxentra Dockerfile:

FROM node:20-slim

WORKDIR /app

COPY package*.json ./

RUN npm ci --production

COPY . .

RUN npm run build

USER node

EXPOSE 3000

CMD ["npm", "start"]

Python / Django / FastAPI

Heroku Procfile:

web: gunicorn myapp.wsgi --bind 0.0.0.0:$PORT

worker: celery -A myapp worker

Arxentra Dockerfile:

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN groupadd -r appuser && useradd -r -g appuser appuser

USER appuser

EXPOSE 8000

CMD ["gunicorn", "myapp.wsgi", "--bind", "0.0.0.0:8000", "--workers", "4"]

Ruby / Rails

Heroku Procfile:

web: bundle exec puma -C config/puma.rb

worker: bundle exec sidekiq

Arxentra Dockerfile:

FROM ruby:3.3-slim

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev

WORKDIR /app

COPY Gemfile Gemfile.lock ./

RUN bundle install --without development test

COPY . .

RUN bundle exec rake assets:precompile

USER nobody

EXPOSE 3000

CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

Key differences from Heroku:

  • Heroku automatically detects the port from `$PORT` environment variable. In Docker, you explicitly set the port in the `CMD` and `EXPOSE` directives.
  • Heroku buildpacks install system dependencies automatically. In Docker, you install them explicitly with `apt-get`.
  • Workers are deployed as separate services on Arxentra, each with their own Dockerfile (or a shared Dockerfile with different CMD).

Step 2: Replace Heroku add-ons

Heroku add-ons are replaced with Arxentra managed services or compatible alternatives:

Heroku add-onArxentra replacementMonthly cost comparison

Heroku Postgres (Mini)Managed PostgreSQL Starter$5 vs $7
Heroku Postgres (Basic)Managed PostgreSQL Starter$9 vs $7
Heroku Postgres (Standard 0)Managed PostgreSQL Pro$50 vs $15
Heroku Postgres (Standard 2)Managed PostgreSQL Pro$200 vs $30
Heroku Data for Redis (Mini)Managed Redis Starter$3 vs $5
Heroku Data for Redis (Premium 0)Managed Redis Pro$15 vs $10
Heroku SchedulerArxentra Cron JobsFree vs Included
PapertrailBuilt-in log viewer$7+ vs Included
New Relic APMBuilt-in monitoring$0 (limited) vs Included
MailgunMailgun (direct)Same pricing
CloudinaryCloudinary (direct)Same pricing
Heroku SSLLet's Encrypt (auto)$20 vs Free

For add-ons that are external SaaS services (Mailgun, Cloudinary, SendGrid), keep using them directly. You only need to update the connection details in your environment variables.

Step 3: Deploy to Arxentra

  • Push your code (with new Dockerfile) to GitHub
  • Create a new service in the Arxentra dashboard
  • Connect your GitHub repository
  • Arxentra detects the Dockerfile and builds automatically
  • Set environment variables (Step 5 below)
  • Deploy
  • Create separate services for workers:

    <h1>arxentra.yaml</h1>
    

    services:

    web:

    build: ./Dockerfile

    port: 3000

    worker:

    build: ./Dockerfile

    command: npm run worker

    databases:

    postgres:

    type: postgresql

    plan: pro

    redis:

    type: redis

    plan: starter

    Step 4: Migrate PostgreSQL data

    This is the most critical step. Follow this process for zero-downtime data migration:

    4a. Export data from Heroku

    <h1>Get Heroku database URL</h1>
    

    heroku config:get DATABASE_URL -a your-app-name

    <h1>Export database (schema + data)</h1>

    pg_dump -Fc --no-owner --no-acl \

    "$(heroku config:get DATABASE_URL -a your-app-name)" \

    > heroku_backup.dump

    <h1>Verify the dump file</h1>

    pg_restore --list heroku_backup.dump | head -20

    Important flags:

    • `-Fc`: Custom format (compressed, supports parallel restore)
    • `--no-owner`: Don't set ownership (Arxentra uses a different user)
    • `--no-acl`: Don't restore access privileges (Arxentra manages permissions)

    4b. Import data to Arxentra

    <h1>Get Arxentra database connection string from dashboard</h1>
    

    <h1>Or use: arx db connection-string postgres</h1>

    <h1>Restore to Arxentra database</h1>

    pg_restore --no-owner --no-acl --clean --if-exists \

    -d "postgresql://user:password@host:5432/dbname" \

    heroku_backup.dump

    <h1>Verify row counts match</h1>

    psql "postgresql://user:password@host:5432/dbname" \

    -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC;"

    4c. Handle sequences

    After importing, PostgreSQL sequences may be out of sync. Reset them:

    -- Reset all sequences to match current max values
    

    DO $$

    DECLARE

    r RECORD;

    BEGIN

    FOR r IN

    SELECT

    pg_get_serial_sequence(t.table_name, c.column_name) AS seq,

    t.table_name,

    c.column_name

    FROM information_schema.tables t

    JOIN information_schema.columns c ON t.table_name = c.table_name

    WHERE t.table_schema = 'public'

    AND c.column_default LIKE 'nextval%'

    LOOP

    EXECUTE format(

    'SELECT setval(%L, COALESCE(MAX(%I), 1)) FROM %I',

    r.seq, r.column_name, r.table_name

    );

    END LOOP;

    END $$;

    4d. For large databases (10 GB+)

    For databases larger than 10 GB, use parallel dump and restore:

    <h1>Parallel dump (4 jobs)</h1>
    

    pg_dump -Fd --jobs=4 --no-owner --no-acl \

    "$(heroku config:get DATABASE_URL)" \

    -f heroku_backup_dir

    <h1>Parallel restore (4 jobs)</h1>

    pg_restore --no-owner --no-acl --clean --if-exists --jobs=4 \

    -d "postgresql://user:password@host:5432/dbname" \

    heroku_backup_dir

    For databases over 50 GB, consider using `pg_dump` with `--section` to migrate schema and data separately, allowing you to create indexes after the data import completes.

    Step 5: Configure environment variables

    Export your Heroku environment variables and import them to Arxentra:

    <h1>Export Heroku config vars</h1>
    

    heroku config -a your-app-name --json > heroku_config.json

    <h1>Or export as .env format</h1>

    heroku config -a your-app-name --shell > .env.heroku

    <h1>Import to Arxentra</h1>

    arx env import .env.heroku

    Variables that need updating:

    VariableHeroku valueArxentra value

    DATABASE_URLHeroku Postgres URLArxentra Postgres URL (auto-injected)
    REDIS_URLHeroku Redis URLArxentra Redis URL (auto-injected)
    PORTSet by HerokuSet in Dockerfile (EXPOSE)
    HOST0.0.0.00.0.0.0 (same)
    APP_URLyour-app.herokuapp.comyourdomain.com

    Remove Heroku-specific variables: `HEROKU_APP_NAME`, `HEROKU_DYNO_ID`, `HEROKU_SLUG_COMMIT`. These are injected by Heroku and are not needed on Arxentra.

    Step 6: DNS cutover

    This is the only step that can cause brief downtime (0-5 minutes of DNS propagation).

    Pre-cutover checklist

  • Verify the Arxentra deployment works by accessing the `.arxentra.app` URL
  • Verify database data is correct and complete
  • Test all critical user flows (login, signup, core features)
  • Lower DNS TTL to 60 seconds at least 24 hours before cutover
  • Cutover process

    <h1>1. Put Heroku app in maintenance mode</h1>
    

    heroku maintenance:on -a your-app-name

    <h1>2. Final database sync (capture any writes since initial migration)</h1>

    pg_dump -Fc --no-owner --no-acl --data-only \

    "$(heroku config:get DATABASE_URL -a your-app-name)" \

    > final_sync.dump

    pg_restore --no-owner --no-acl --data-only \

    -d "postgresql://user:password@host:5432/dbname" \

    final_sync.dump

    <h1>3. Update DNS records</h1>

    <h1>Remove: CNAME your-app.herokuapp.com</h1>

    <h1>Add: CNAME your-service.arxentra.app (or A record)</h1>

    <h1>4. Verify DNS propagation</h1>

    dig yourdomain.com +short

    <h1>5. Verify application works on new domain</h1>

    curl -I https://yourdomain.com

    <h1>6. Disable Heroku maintenance mode (as fallback)</h1>

    <h1>heroku maintenance:off -a your-app-name</h1>

    Zero-downtime cutover (advanced)

    For zero-downtime cutover, run both platforms simultaneously:

  • Keep Heroku running and Arxentra running with the same database (Arxentra connecting to a read replica or using Heroku's database temporarily)
  • Switch DNS to Arxentra
  • After DNS propagation is complete (verify with multiple DNS resolvers), switch Arxentra to its own database
  • Decommission Heroku
  • This approach requires running two databases briefly but eliminates any possible downtime.

    Step 7: Verify and decommission

    After cutover, monitor the Arxentra deployment for 24-48 hours before decommissioning Heroku:

  • Monitor error rates, response times, and user reports
  • Check that all background jobs (worker, scheduler) are running correctly
  • Verify webhook endpoints are receiving data (update webhook URLs at third-party services)
  • Check that SSL certificate is active and auto-renewing
  • Confirm email delivery works (update SPF/DKIM if your sending domain references Heroku IPs)
  • Once verified, decommission Heroku:

    <h1>Scale down dynos (stop charges immediately)</h1>
    

    heroku ps:scale web=0 worker=0 -a your-app-name

    <h1>Remove add-ons (after confirming data is migrated)</h1>

    heroku addons:destroy heroku-postgresql -a your-app-name --confirm your-app-name

    heroku addons:destroy heroku-redis -a your-app-name --confirm your-app-name

    <h1>Delete app (after 30-day observation period)</h1>

    heroku apps:destroy your-app-name --confirm your-app-name

    Cost comparison

    A typical Heroku-to-Arxentra migration for a production SaaS application:

    ComponentHerokuArxentraSavings

    Web dyno (Standard-1X, 512 MB)$25/mo$5/mo (1 GB)80%
    Web dyno (Standard-2X, 1 GB)$50/mo$10/mo (2 GB)80%
    Worker dyno (Standard-1X)$25/mo$5/mo80%
    PostgreSQL (Standard 0)$50/mo$15/mo70%
    Redis (Premium 0)$15/mo$10/mo33%
    SSL$0 (included)$0 (included)--
    Logging (Papertrail)$7/moIncluded100%
    Monitoring$0 (limited)Included--
    Production stack total$172/mo$45/mo74%
    Annual total$2,064$540$1,524 saved

    The most significant savings come from compute: Heroku's Standard-1X dyno ($25/month for 512 MB RAM on shared CPU) is replaced by an Arxentra container ($5/month for 1 GB RAM on dedicated vCPU). You get 2x the memory and dedicated CPU for 80% less.

    Frequently asked questions

    Q: Can I migrate a Heroku pipeline (staging + production)?

    Yes. Create two services on Arxentra: one connected to your `staging` branch and one connected to your `main` branch. Configure separate environment variables for each. Arxentra does not have a "pipeline" concept, but branch-based deployments achieve the same result.

    Q: What about Heroku review apps?

    Arxentra's preview deployments replace Heroku review apps. Every pull request gets a unique URL for testing. Preview deployments share the same database as staging by default, or you can configure isolated databases for each preview.

    Q: My app uses Heroku's ephemeral filesystem. Will it work?

    Arxentra containers also have ephemeral filesystems -- files written to disk are lost on restart. If your app writes temporary files (uploads, generated PDFs), this works the same way. If your app relies on persistent local storage, migrate those files to object storage.

    Q: How do I handle Heroku buildpack-specific features?

    Heroku buildpacks handle language-specific setup automatically. In Docker, you handle this explicitly. Common buildpack features and their Docker equivalents: (1) Node.js buildpack: use `node:20-slim` base image + `npm ci`. (2) Python buildpack: use `python:3.12-slim` base image + `pip install`. (3) Multi-buildpack: use multi-stage Docker builds.

    Q: What about Heroku Postgres follower databases?

    Arxentra managed PostgreSQL supports read replicas on Pro plans and above. Create a read replica from the database dashboard. Your application connects to the replica using a separate `DATABASE_REPLICA_URL` environment variable.

    Q: Can I keep using the Heroku CLI during migration?

    Yes. During the migration period, you can use both `heroku` and `arx` CLIs simultaneously. The Heroku CLI continues to manage your Heroku resources, and the Arxentra CLI manages your new deployment. After decommissioning Heroku, uninstall the Heroku CLI: `npm uninstall -g heroku`.

    For more platform comparisons, see Arxentra vs AWS or Best AWS Alternatives for Startups.