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.
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.
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.


