Complete PaaS Exit Playbook: Heroku to Self-Hosted in 72 Hours
Step-by-step migration from Heroku/Render to self-hosted Docker. Real case study: $2,800/mo to $240/mo. Same deploy experience.
# Complete PaaS Exit Playbook: Heroku to Self-Hosted in 72 Hours
We've migrated 6 startups off Heroku and Render in the past year. Average cost reduction: 87%. No client has gone back.
This is the exact playbook we use. Three days, start to finish.
The Economics That Force the Move
Here's a real client breakdown (Series A, Rails app, ~5K DAU):
|-------------|-------------|
The replacement:
|-----------------|-------------|
Actual client paid $240/mo because they chose managed Postgres on a larger plan and a beefier server for headroom. Still 91% savings.
Day 1: Containerize (8 hours)
Step 1: Create a Dockerfile
If you're on Heroku, you likely have a Procfile. The translation is direct:
# Heroku Procfile: web: bundle exec puma -C config/puma.rb
# Docker equivalent:
FROM ruby:3.2-slim AS base
WORKDIR /app
# Install dependencies
RUN apt-get update && apt-get install -y \
build-essential libpq-dev nodejs npm && \
rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./
RUN bundle install --deployment --without development test
COPY . .
RUN bundle exec rake assets:precompile
# Production stage
FROM ruby:3.2-slim
WORKDIR /app
RUN apt-get update && apt-get install -y libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY --from=base /app /app
USER 1000:1000
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]Step 2: Create docker-compose.yml
services:
app:
build: .
user: "1000:1000"
ports:
- "127.0.0.1:3000:3000"
environment:
- DATABASE_URL=postgres://app:${DB_PASS}@postgres:5432/app_prod
- REDIS_URL=redis://redis:6379/0
- RAILS_ENV=production
- SECRET_KEY_BASE=${SECRET_KEY}
depends_on:
- postgres
- redis
deploy:
resources:
limits:
memory: 1G
cpus: '2.0'
networks:
- backend
postgres:
image: postgres:16-alpine
user: "999:999"
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_DB=app_prod
deploy:
resources:
limits:
memory: 1G
networks:
- backend
redis:
image: redis:7-alpine
volumes:
- redisdata:/data
deploy:
resources:
limits:
memory: 256M
networks:
- backend
traefik:
image: traefik:v3
ports:
- "443:443"
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik:/etc/traefik
networks:
- backend
volumes:
pgdata:
redisdata:
networks:
backend:Step 3: Test locally
docker compose up --build
# Hit localhost:3000, verify everything works
# Run your test suite against DockerDay 2: Provision and Migrate Data (8 hours)
Step 1: Provision the server
# Hetzner CLI (or use their web UI)
hcloud server create \
--name prod-01 \
--type cx41 \
--image ubuntu-24.04 \
--ssh-key my-key \
--location nbg1Step 2: Bootstrap the server
# SSH in and run
apt update && apt upgrade -y
apt install -y docker.io docker-compose-v2
systemctl enable docker
# Create deploy user
useradd -m -s /bin/bash deploy
usermod -aG docker deploy
# Set up firewall
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableStep 3: Migrate the database
# Export from Heroku
heroku pg:backups:capture --app your-app
heroku pg:backups:download --app your-app
# Import to new Postgres
docker compose up -d postgres
docker compose exec -T postgres pg_restore \
-U postgres -d app_prod < latest.dumpStep 4: Migrate files/assets
If using Heroku's ephemeral filesystem, you're probably already on S3. Just update the credentials in your env.
If using Heroku's built-in file storage... that data is gone on every deploy anyway. Nothing to migrate.
Day 3: Go Live (4 hours)
Step 1: Deploy and verify
# On the server
docker compose up -d
docker compose logs -f app # Watch for startup errors
# Health check
curl -s https://your-domain.com/health | jq .Step 2: Set up CI/CD
# .gitea/workflows/deploy.yml (or .github/workflows)
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
run: |
ssh deploy@your-server "cd /app && git pull && docker compose up -d --build"Now git push deploys — same as Heroku.
Step 3: Flip DNS
# Update your domain's A record to the new server IP
# TTL: start at 60 seconds, increase after verificationStep 4: Monitor for 48 hours
Keep Heroku running for 48 hours as rollback. Watch:
What You Keep
|---------------|----------------------|
git push deploydocker compose up -d --build previous commitWhat You Gain
When NOT to Self-Host
Be honest with yourself:
For the other 90% of startups: you're overpaying for convenience you've already outgrown.
Free Migration Assessment
Not sure if migration makes sense for your stack? We'll review your current Heroku/Render setup, estimate your self-hosted costs, and give you an honest recommendation in 15 minutes.
Book a call: techsaas.cloud/contacttechsaas.cloud/contacthttps://techsaas.cloud/contact
Need help with cloud & infrastructure?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.