Post

From Heroku to DigitalOcean: $4/month Side Project Deploys

I moved a side project from Heroku to a $4 DigitalOcean droplet using Kamal and SQLite, and cut my monthly hosting bill from $16 to $4.

A couple of months ago Heroku announced they were going into a sort of maintenance mode, and a bunch of people I follow online started debating where to move their stuff. We use Heroku where I work, and we’re already moving towards Google Cloud, but for my tiny side projects I wanted something really easy to use so I can just focus on building dumb stuff (and not on infra).

A coworker mentioned DigitalOcean droplets as one of the cheapest options for hosting a PoC of an AI tool we were building at work. Turns out you can get a VM with the same RAM as the first tier from Heroku for just $4 a month, so I picked one of my side projects and moved it over.

The catch is that DigitalOcean gives you a blank VM, which is a bit more work than Heroku and its buildpacks. That didn’t really scare me since I’ve done manual deploys with ssh + docker + nginx before, but I took this as an excuse to finally try Kamal.

Why Kamal?

If you haven’t heard about Kamal, it’s a tool that lets you deploy your app to any server with basically one command. Under the hood it uses docker containers and does zero-downtime deploys (it boots a new container, waits for health checks, switches traffic, and only then drains the old one). The part I cared about the most was the Traefik + Let’s Encrypt integration so I never had to touch certbot or nginx configs again, but it also gives you a nice kamal app logs and kamal app exec for when you need to poke at the server. You just throw your server’s IP into the Kamal config file and you’re good to go.

You also need an image registry, but if you’re on GitHub you can use ghcr.io which has a high free tier (and even higher for PRO accounts).

💡 Tip: Kamal works for any language, not just Rails. You can use it to deploy any kind of app you want. Check out the official docs and the GitHub repo.

Now, this isn’t a Kamal guide, but after configuring the IP and a couple of small things, my config/deploy.yml ended up looking something like this (the side project I migrated is on Rails 7, so I used Kamal 1.x instead of 2.x, the config might be a little different now):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
service: side-project

image: matiassalles99/side-project

servers:
  web:
    hosts:
      - YOUR_DROPLET_IP
    options:
      volume:
        - side_project_db:/rails/db
    labels:
      traefik.http.routers.side-project-web.rule: Host(`yourdomain.com`) || Host(`www.yourdomain.com`)
      traefik.http.routers.side-project-web.tls: true
      traefik.http.routers.side-project-web.tls.certresolver: letsencrypt
      traefik.http.routers.side-project-web.entrypoints: websecure

registry:
  server: ghcr.io
  username: matiassalles99
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  clear:
    RAILS_ENV: production
    RAILS_LOG_TO_STDOUT: enabled
    RAILS_SERVE_STATIC_FILES: enabled
    # ...your non-secret env vars
  secret:
    - SECRET_KEY_BASE
    # ...your secret env vars

traefik:
  options:
    publish:
      - 443:443
    volume:
      - /letsencrypt:/letsencrypt
  args:
    entryPoints.web.address: ":80"
    entryPoints.websecure.address: ":443"
    entryPoints.web.http.redirections.entryPoint.to: websecure
    entryPoints.web.http.redirections.entryPoint.scheme: https
    certificatesResolvers.letsencrypt.acme.email: youremail@example.com
    certificatesResolvers.letsencrypt.acme.storage: /letsencrypt/acme.json
    certificatesResolvers.letsencrypt.acme.httpChallenge.entryPoint: web

builder:
  multiarch: false

asset_path: /rails/public/assets

Now, we would be ready at this point, but before running bundle exec kamal deploy I decided to do one more thing.

one more thing gif

Dropping Postgres for SQLite

⚠️ Warning: This part only works because we’re on a VM with a persistent disk. You can’t run SQLite on Heroku since their filesystem is ephemeral and gets wiped on every dyno restart, so the DB would basically disappear on you.

DHH literally said Basecamp runs ONCE/Campfire on SQLite, and Pieter Levels has been hyping it for a while now. Rails 8 already ships with production-leaning SQLite defaults, and there’s even a whole High Performance SQLite course (no affiliation, it just looks really good).

Swapping Postgres for SQLite on a small project saves you around $9/month on database hosting, which is great considering most side projects aren’t going to have 72,000 users hammering writes to the DB concurrently…

So I backed up my Heroku DB, downloaded the dump, and used AI to convert it to an SQLite-compatible format. I also had to swap a few Postgres-specific column types (mostly text and one jsonb that became a plain string column). After that, I was ready to deploy my whole app for $4 a month, where I was paying $7 + $9 (Heroku web dyno + Postgres) before.

ℹ️ Note: I also turned on DigitalOcean’s weekly backups for the droplet, which is another $1.60 a month.

Feeling smart after cutting the monthly hosting bill by two thirds

The numbers

So just to put the actual numbers side by side:

1
2
3
4
5
6
7
Heroku web dyno:    ~$7/month
Heroku Postgres:    ~$9/month
Total before:       ~$16/month

DigitalOcean VM:    ~$4/month
Weekly backups:     ~$1.60/month
Total after:        ~$5.60/month

That’s roughly a third of what I was paying before. And since I ended up doing the same thing across 3 side projects, the savings actually look more like this:

1
2
3
3 apps on Heroku:        ~$48/month   (~$576/year)
3 apps on DigitalOcean:  ~$16.80/month (~$201/year)
Yearly savings:          ~$375

As a side bonus pages also feel faster, maybe SQLite reads on the same box just beat going over the network to Heroku Postgres.

Final thoughts

A couple of months in I haven’t seen a single failed deploy, and kamal deploy consistently takes around a minute. The Rails 7 to SQLite switch was pretty uneventful too, so I imagine it’s even less painful if you’re on Rails 8.

The one thing I want to play with next, more out of curiosity than anything else, is running a couple of tiny apps on the same droplet (you can do this with Kamal by giving each app its own subdomain through Traefik). On a 512MB RAM VM you’re obviously not going to fit two Rails apps, but I’d love to see how far you can push it with smaller stuff. When I get around to that I’ll write it up.

If you’re going down the same indie hosting rabbit hole, I also wrote about how I cut my domain bill by 50% in Cheap Domains for Indie Hackers: Why I Moved to Porkbun.

This post is licensed under CC BY 4.0 by the author.