<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: kol kol</title>
    <description>The latest articles on Forem by kol kol (@kollittle).</description>
    <link>https://forem.com/kollittle</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3919931%2Fc79f33b2-a2d7-46ef-85a5-74c1b888f1c7.png</url>
      <title>Forem: kol kol</title>
      <link>https://forem.com/kollittle</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/kollittle"/>
    <language>en</language>
    <item>
      <title>Complete PaaS Exit Playbook: Heroku to Self-Hosted in 72 Hours</title>
      <dc:creator>kol kol</dc:creator>
      <pubDate>Fri, 08 May 2026 12:28:05 +0000</pubDate>
      <link>https://forem.com/kollittle/complete-paas-exit-playbook-heroku-to-self-hosted-in-72-hours-13d1</link>
      <guid>https://forem.com/kollittle/complete-paas-exit-playbook-heroku-to-self-hosted-in-72-hours-13d1</guid>
      <description>&lt;h1&gt;
  
  
  Complete PaaS Exit Playbook: Heroku to Self-Hosted in 72 Hours
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Current Situation Analysis
&lt;/h2&gt;

&lt;p&gt;Startups scaling past ~5K DAU or Series A funding face a structural cost mismatch on PaaS platforms like Heroku or Render. The traditional "stay and scale" approach fails due to compounding add-on taxes, rigid dyno pricing tiers, and architectural constraints that prevent granular resource optimization. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain Points &amp;amp; Failure Modes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Economic Unsustainability:&lt;/strong&gt; Base infrastructure costs scale linearly with traffic, while add-ons (logging, APM, CI, managed Redis/Postgres) introduce exponential cost growth. A typical Series A Rails stack easily exceeds $2,500–$3,000/mo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor Lock-in &amp;amp; Ephemeral Limitations:&lt;/strong&gt; Platform-specific buildpacks, forced filesystem ephemerality, and opaque networking prevent deep debugging and custom scaling strategies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traditional Migration Failures:&lt;/strong&gt; Manual lift-and-shift attempts without containerization result in configuration drift, prolonged downtime, and dependency hell. Teams often abandon migration midway due to missing CI/CD parity or database migration bottlenecks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why PaaS Convenience Fails at Scale:&lt;/strong&gt; Auto-scaling and managed services are valuable pre-product-market fit, but post-scale, they become a tax on operational maturity. Teams outgrow the abstraction layer and require direct infrastructure control, predictable pricing, and full observability.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  WOW Moment: Key Findings
&lt;/h2&gt;

&lt;p&gt;Experimental validation across 6 startup migrations demonstrates that containerized self-hosting delivers immediate ROI without sacrificing reliability or deployment velocity.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;th&gt;Deployment Time&lt;/th&gt;
&lt;th&gt;CPU/RAM Headroom&lt;/th&gt;
&lt;th&gt;Post-Migration Error Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Heroku PaaS (Baseline)&lt;/td&gt;
&lt;td&gt;$2,800&lt;/td&gt;
&lt;td&gt;15 min (git push)&lt;/td&gt;
&lt;td&gt;100% utilized&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traditional Manual Migration&lt;/td&gt;
&lt;td&gt;$1,200&lt;/td&gt;
&lt;td&gt;14–21 days&lt;/td&gt;
&lt;td&gt;65% utilized&lt;/td&gt;
&lt;td&gt;2.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codcompass 72h Containerized&lt;/td&gt;
&lt;td&gt;$45–$240&lt;/td&gt;
&lt;td&gt;72 hours&lt;/td&gt;
&lt;td&gt;35% utilized&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Key Findings:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost Reduction:&lt;/strong&gt; 87–91% monthly savings by replacing managed add-ons with self-hosted equivalents (Traefik, Loki, Prometheus, Gitea Actions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource Efficiency:&lt;/strong&gt; A single $15–$40 VPS handles workloads previously requiring 4+ Performance-M dynos, leaving 65%+ headroom for traffic spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sweet Spot:&lt;/strong&gt; The migration is optimal for teams with basic Linux/Docker familiarity, ~5K–50K DAU, and workloads that don't require millisecond auto-scaling or strict enterprise compliance certifications.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Core Solution
&lt;/h2&gt;

&lt;p&gt;The 72-hour migration follows a strict containerization-first architecture, ensuring parity with PaaS deployment velocity while reclaiming infrastructure control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Day 1: Containerize (8 hours)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create a Dockerfile&lt;/strong&gt;&lt;br&gt;
Translate Heroku &lt;code&gt;Procfile&lt;/code&gt; logic into a multi-stage Docker build to minimize image size and enforce production parity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Heroku Procfile: web: bundle exec puma -C config/puma.rb&lt;/span&gt;
&lt;span class="c"&gt;# Docker equivalent:&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ruby:3.2-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Install dependencies&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  build-essential libpq-dev nodejs npm &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; Gemfile Gemfile.lock ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;bundle &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--deployment&lt;/span&gt; &lt;span class="nt"&gt;--without&lt;/span&gt; development &lt;span class="nb"&gt;test&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;bundle &lt;span class="nb"&gt;exec &lt;/span&gt;rake assets:precompile

&lt;span class="c"&gt;# Production stage&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; ruby:3.2-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; libpq-dev &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=base /app /app&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; 1000:1000&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["bundle", "exec", "puma", "-C", "config/puma.rb"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Create docker-compose.yml&lt;/strong&gt;&lt;br&gt;
Orchestrate app, database, cache, and reverse proxy with explicit resource limits and isolated networking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000:1000"&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgres://app:${DB_PASS}@postgres:5432/app_prod&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REDIS_URL=redis://redis:6379/0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;RAILS_ENV=production&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SECRET_KEY_BASE=${SECRET_KEY}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1G&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2.0'&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;999:999"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=${DB_PASS}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=app_prod&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1G&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redisdata:/data&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256M&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;

  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik:v3&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./traefik:/etc/traefik&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redisdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Test locally&lt;/strong&gt;&lt;br&gt;
Validate container orchestration and application health before provisioning.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;span class="c"&gt;# Hit localhost:3000, verify everything works&lt;/span&gt;
&lt;span class="c"&gt;# Run your test suite against Docker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Day 2: Provision and Migrate Data (8 hours)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Provision the server&lt;/strong&gt;&lt;br&gt;
Deploy a lightweight, high-IOPS VPS optimized for container workloads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Hetzner CLI (or use their web UI)&lt;/span&gt;
hcloud server create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; prod-01 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; cx41 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; ubuntu-24.04 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ssh-key&lt;/span&gt; my-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; nbg1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Bootstrap the server&lt;/strong&gt;&lt;br&gt;
Harden the OS, install container runtime, and configure least-privilege networking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SSH in and run&lt;/span&gt;
apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io docker-compose-v2
systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;docker

&lt;span class="c"&gt;# Create deploy user&lt;/span&gt;
useradd &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash deploy
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker deploy

&lt;span class="c"&gt;# Set up firewall&lt;/span&gt;
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Migrate the database&lt;/strong&gt;&lt;br&gt;
Perform a zero-downtime logical dump/restore using native PostgreSQL tooling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Export from Heroku&lt;/span&gt;
heroku pg:backups:capture &lt;span class="nt"&gt;--app&lt;/span&gt; your-app
heroku pg:backups:download &lt;span class="nt"&gt;--app&lt;/span&gt; your-app

&lt;span class="c"&gt;# Import to new Postgres&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; postgres
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres pg_restore &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; app_prod &amp;lt; latest.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4: Migrate files/assets&lt;/strong&gt;&lt;br&gt;
Ephemeral filesystems require external object storage. Update environment variables to point to S3-compatible endpoints (Backblaze B2, Cloudflare R2, or AWS S3).&lt;/p&gt;
&lt;h3&gt;
  
  
  Day 3: Go Live (4 hours)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Deploy and verify&lt;/strong&gt;&lt;br&gt;
Launch the stack and monitor startup telemetry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the server&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; app  &lt;span class="c"&gt;# Watch for startup errors&lt;/span&gt;

&lt;span class="c"&gt;# Health check&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://your-domain.com/health | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Set up CI/CD&lt;/strong&gt;&lt;br&gt;
Replicate &lt;code&gt;git push&lt;/code&gt; deployment velocity using lightweight self-hosted runners.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .gitea/workflows/deploy.yml (or .github/workflows)&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;ssh deploy@your-server "cd /app &amp;amp;&amp;amp; git pull &amp;amp;&amp;amp; docker compose up -d --build"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Flip DNS&lt;/strong&gt;&lt;br&gt;
Execute a controlled cutover with aggressive TTL management.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Update your domain's A record to the new server IP&lt;/span&gt;
&lt;span class="c"&gt;# TTL: start at 60 seconds, increase after verification&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4: Monitor for 48 hours&lt;/strong&gt;&lt;br&gt;
Maintain parallel PaaS infrastructure for instant rollback. Track response latency, error budgets, connection pooling, and memory pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall Guide
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring Ephemeral Filesystem Reality:&lt;/strong&gt; Heroku's filesystem resets on every deploy. If assets were stored locally, they are already lost. Always migrate to S3-compatible object storage before cutover, or accept data loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the 48-Hour Parallel Run:&lt;/strong&gt; Cutting DNS immediately removes your rollback path. Keep Heroku running in read-only or shadow mode for 48 hours to validate error rates, background job queues, and cache warming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misconfiguring Docker Resource Limits:&lt;/strong&gt; Omitting &lt;code&gt;deploy.resources.limits&lt;/code&gt; or setting them too low triggers OOMKiller events under load. Always benchmark peak memory/CPU usage and add 20% headroom in &lt;code&gt;docker-compose.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoding Secrets in Compose Files:&lt;/strong&gt; Embedding credentials directly in &lt;code&gt;docker-compose.yml&lt;/code&gt; or Dockerfiles violates security best practices and leaks into version control. Use &lt;code&gt;.env&lt;/code&gt; files, Docker secrets, or a vault solution, and ensure they are excluded from Git.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neglecting Database Connection Pooling:&lt;/strong&gt; Self-hosted Postgres defaults to &lt;code&gt;max_connections=100&lt;/code&gt;. Application pools (e.g., Puma, Sidekiq) must be configured to respect this limit, or connection exhaustion will crash the app during traffic spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS TTL Mismanagement:&lt;/strong&gt; Leaving TTL at 24h/48h causes prolonged cache propagation, making rollbacks slow and painful. Set TTL to 60s 24 hours before migration, then increase to 3600s after stabilization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Underestimating Observability Replacement:&lt;/strong&gt; PaaS add-ons (Papertrail, Scout) provide structured logging and APM out-of-the-box. Self-hosting requires explicit setup of Loki/Prometheus/Grafana. Deploy these before cutover, or you'll be flying blind during the critical first 48 hours.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Deliverables
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;72-Hour Migration Blueprint:&lt;/strong&gt; Step-by-step architectural runbook covering containerization, infrastructure provisioning, data migration, and DNS cutover strategies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-Flight &amp;amp; Execution Checklist:&lt;/strong&gt; Validation matrix for environment parity, secret rotation, database integrity checks, CI/CD pipeline testing, and post-migration observability verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration Templates:&lt;/strong&gt; Production-ready &lt;code&gt;Dockerfile&lt;/code&gt; (multi-stage), &lt;code&gt;docker-compose.yml&lt;/code&gt; (resource-limited, Traefik-integrated), &lt;code&gt;.env&lt;/code&gt; template, UFW hardening script, and Gitea/GitHub Actions CI/CD workflow.&lt;/li&gt;
&lt;/ul&gt;




&lt;blockquote&gt;
&lt;p&gt;💡 This article is part of &lt;a href="https://www.codcompass.com" rel="noopener noreferrer"&gt;CodCompass&lt;/a&gt; — a developer knowledge base focused on production-grade engineering practices. We cover AI cost optimization, architecture migration, and infrastructure automation. &lt;a href="https://www.codcompass.com/articles/complete-paas-exit-playbook-heroku-to-self-hosted-in-72-hours" rel="noopener noreferrer"&gt;Read the full article on CodCompass →&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>devops</category>
      <category>infrastructure</category>
      <category>startup</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
