← Back

Self-Hosting on a Raspberry Pi: Building a Mini-Heroku for $0/month

How I turned a Raspberry Pi 5 sitting on my desk into a personal deployment platform with git-push deploys, automatic HTTPS, Cloudflare Tunnel, and full Grafana monitoring — replacing paid cloud services with hardware I already owned.

Self-Hosting em um Raspberry Pi: Construindo um Mini-Heroku por R$0/mes

Como transformei um Raspberry Pi 5 na minha mesa em uma plataforma pessoal de deploy com git push, HTTPS automatico, Cloudflare Tunnel e monitoramento completo com Grafana — substituindo servicos pagos na nuvem por hardware que eu ja tinha.

Why Self-Host?

I had a Raspberry Pi 5 sitting on my desk doing nothing. I also had a side project — an Express API with Postgres and Redis — running on Fly.io, costing me about $5/month. Not a lot, but it adds up when you want to run multiple experiments.

The real motivation wasn't cost savings. It was control and learning. I wanted to understand every layer of the deployment stack: DNS resolution, reverse proxies, SSL certificates, process management, container orchestration, monitoring. When you deploy to Heroku or Fly.io, these layers are abstracted away. That's their value proposition. But as an engineer, I wanted to peel back those layers.

The goal: a personal platform where git push raspi main deploys my code to the internet. Like a one-person Heroku, running on a $80 single-board computer in my living room in Sao Paulo.

Por que Self-Host?

Eu tinha um Raspberry Pi 5 na minha mesa sem fazer nada. Tambem tinha um side project — uma API Express com Postgres e Redis — rodando no Fly.io, custando cerca de $5/mes. Nao e muito, mas acumula quando voce quer rodar varios experimentos.

A motivacao real nao era economizar. Era controle e aprendizado. Eu queria entender cada camada do stack de deploy: resolucao DNS, reverse proxies, certificados SSL, gerenciamento de processos, orquestracao de containers, monitoramento. Quando voce faz deploy no Heroku ou Fly.io, essas camadas sao abstraidas. Esse e o valor deles. Mas como engenheiro, eu queria descascar essas camadas.

O objetivo: uma plataforma pessoal onde git push raspi main faz deploy do meu codigo para a internet. Como um Heroku de uma pessoa so, rodando em um computador de R$400 na minha sala em Sao Paulo.

The Architecture

The design had to support a mix of project types — bare Node.js apps managed by PM2, and containerized projects running in Docker. Each project gets its own subdomain, automatically routed through Nginx.

A Arquitetura

O design tinha que suportar diferentes tipos de projeto — apps Node.js gerenciados pelo PM2, e projetos containerizados rodando em Docker. Cada projeto recebe seu proprio subdominio, roteado automaticamente pelo Nginx.

Internet → Cloudflare DNS → Cloudflare Tunnel → Raspberry Pi │ cloudflared │ Nginx (port 443) reverse proxy + SSL │ ┌───────────┴───────────┐ │ │ PM2 projects Docker projects (bare Node.js) (containerized) port 3001-3050 port 4001-4050

The Components

  • Nginx — single entry point, routes each subdomain (e.g., api.eduardolopes.dev) to the right internal port
  • PM2 — process manager for bare Node.js apps. Auto-restart, logs, monitoring
  • Docker — runs containerized projects with their own Dockerfile and dependencies
  • Git bare repos — on the Pi, with post-receive hooks that trigger deployment on push
  • Cloudflare Tunnel — exposes the Pi to the internet without opening router ports (more on why later)
  • Certbot — wildcard HTTPS certificate for *.eduardolopes.dev
  • Prometheus + Grafana — full system and application monitoring

Os Componentes

  • Nginx — ponto de entrada unico, roteia cada subdominio (ex: api.eduardolopes.dev) para a porta interna correta
  • PM2 — gerenciador de processos para apps Node.js. Auto-restart, logs, monitoramento
  • Docker — roda projetos containerizados com seu proprio Dockerfile e dependencias
  • Git bare repos — no Pi, com hooks post-receive que disparam o deploy no push
  • Cloudflare Tunnel — expoe o Pi para a internet sem abrir portas no roteador (mais sobre isso adiante)
  • Certbot — certificado HTTPS wildcard para *.eduardolopes.dev
  • Prometheus + Grafana — monitoramento completo do sistema e aplicacoes

The Deploy Pipeline

The heart of the system is a CLI tool I called pi-deploy that lives on the Pi. It handles everything: port allocation, git repo creation, Nginx configuration, and post-receive hook setup.

O Pipeline de Deploy

O coracao do sistema e uma ferramenta CLI que chamei de pi-deploy, que roda no Pi. Ela cuida de tudo: alocacao de portas, criacao de repos git, configuracao do Nginx e setup dos hooks post-receive.

Creating a New Project

Criando um Novo Projeto

# On my Mac — create a PM2 project
ssh raspi pi-deploy create my-api pm2

# Or a Docker project
ssh raspi pi-deploy create my-app docker

# Output:
[pi-deploy] Assigned port 3001 (pm2)
[pi-deploy] Created bare repo: ~/repos/my-api.git
[pi-deploy] Created post-receive hook
[pi-deploy] Nginx configured: my-api.example.dev → port 3001

Behind the scenes, pi-deploy does several things:

  1. Auto-assigns a port from a registry file (~/apps/.ports). PM2 projects get ports 3001-3050, Docker projects get 4001-4050
  2. Creates a bare git repo at ~/repos/my-api.git
  3. Generates a post-receive hook that detects the project type and runs the appropriate deploy steps
  4. Configures Nginx to route the subdomain to the assigned port, with HTTPS via the wildcard cert

Por tras das cenas, o pi-deploy faz varias coisas:

  1. Auto-atribui uma porta de um arquivo de registro (~/apps/.ports). Projetos PM2 recebem portas 3001-3050, projetos Docker recebem 4001-4050
  2. Cria um bare git repo em ~/repos/my-api.git
  3. Gera um hook post-receive que detecta o tipo do projeto e executa os passos de deploy apropriados
  4. Configura o Nginx para rotear o subdominio para a porta atribuida, com HTTPS via certificado wildcard

The Push-to-Deploy Flow

O Fluxo Push-to-Deploy

# Add the remote and deploy
git remote add raspi ssh://raspi/home/user/repos/my-api.git
git push raspi main

# The post-receive hook fires:
# 1. Checks if the push is to 'main' (other branches are ignored)
# 2. Checks out the code to ~/apps/my-api/
# 3. For PM2: npm install → pm2 restart
# 4. For Docker: docker build → docker run

Only pushes to main trigger deployments. Push to a feature branch and the hook exits silently. This prevents accidental deployments during development.

The whole experience feels remarkably close to Heroku: push code, see logs stream back, site is live. But every component is visible and hackable.

Apenas pushes para main disparam deploys. Push para uma branch de feature e o hook sai silenciosamente. Isso previne deploys acidentais durante o desenvolvimento.

A experiencia toda e surpreendentemente parecida com Heroku: push do codigo, ver os logs voltando, site no ar. Mas cada componente e visivel e hackeavel.

The CGNAT Plot Twist

The original plan was straightforward: point my domain's DNS to my home IP, forward ports 80 and 443 on the router to the Pi, done. This is how most self-hosting guides work.

I set up port forwarding on my Vivo router. Tested from my phone on mobile data. Connection timed out.

The culprit: CGNAT (Carrier-Grade NAT). My ISP shares a single public IP across multiple customers. My router doesn't have a real public IP — it sits behind another NAT layer controlled by the ISP. Port forwarding on my router only affects the inner NAT, not the outer one.

O Plot Twist do CGNAT

O plano original era direto: apontar o DNS do meu dominio para meu IP residencial, redirecionar as portas 80 e 443 no roteador para o Pi, pronto. E assim que a maioria dos guias de self-hosting funciona.

Configurei o redirecionamento de portas no meu roteador Vivo. Testei do celular no 5G. Connection timed out.

O culpado: CGNAT (Carrier-Grade NAT). Meu provedor compartilha um unico IP publico entre varios clientes. Meu roteador nao tem um IP publico real — ele esta atras de outra camada de NAT controlada pelo provedor. O redirecionamento de portas no meu roteador so afeta o NAT interno, nao o externo.

How to detect CGNAT

Run traceroute -m 3 8.8.8.8. If there's an extra hop between your router (192.168.x.x) and the first public IP, you're likely behind CGNAT. Also, if your router's WAN IP differs from what curl ifconfig.me returns, that's a definitive sign.

Execute traceroute -m 3 8.8.8.8. Se houver um salto extra entre seu roteador (192.168.x.x) e o primeiro IP publico, voce provavelmente esta atras de CGNAT. Alem disso, se o IP WAN do seu roteador for diferente do que curl ifconfig.me retorna, e um sinal definitivo.

The Solution: Cloudflare Tunnel

Instead of fighting CGNAT (calling the ISP, paying for a static IP), I pivoted to Cloudflare Tunnel. It's free and actually a better architecture:

  • The Pi creates an outbound connection to Cloudflare's edge network
  • Cloudflare routes incoming traffic through that tunnel back to the Pi
  • No inbound ports need to be open — CGNAT becomes irrelevant
  • You get Cloudflare's DDoS protection for free

A Solucao: Cloudflare Tunnel

Em vez de lutar contra o CGNAT (ligar para o provedor, pagar por IP fixo), migrei para Cloudflare Tunnel. E gratuito e na verdade e uma arquitetura melhor:

  • O Pi cria uma conexao de saida para a rede edge da Cloudflare
  • A Cloudflare roteia o trafego de entrada por esse tunel de volta para o Pi
  • Nenhuma porta de entrada precisa estar aberta — CGNAT se torna irrelevante
  • Voce ganha a protecao DDoS da Cloudflare de graca
# Install and authenticate
cloudflared tunnel login
cloudflared tunnel create raspi-server

# Configuration (~/.cloudflared/config.yml)
tunnel: your-tunnel-id
credentials-file: /path/to/credentials.json

ingress:
  - hostname: "*.example.dev"
    service: https://localhost:443
    originRequest:
      noTLSVerify: true
  - service: http_status:404

# Install as systemd service (starts on boot)
sudo cloudflared service install

The noTLSVerify is needed because cloudflared connects to localhost — the Let's Encrypt certificate is for the public domain. It's safe since the connection never leaves the machine.

O noTLSVerify e necessario porque o cloudflared conecta no localhost — o certificado Let's Encrypt e para o dominio publico. E seguro ja que a conexao nunca sai da maquina.

The CGNAT limitation pushed us to a superior architecture. No open ports, free DDoS protection, and it works from any network — even if you move the Pi to a coffee shop.

A limitacao do CGNAT nos empurrou para uma arquitetura superior. Sem portas abertas, protecao DDoS gratuita, e funciona de qualquer rede — mesmo se voce levar o Pi para um cafe.

Wildcard HTTPS with Let's Encrypt

Every project needs HTTPS. Instead of generating a certificate per subdomain, I used a wildcard certificate covering *.eduardolopes.dev. Certbot's Cloudflare plugin handles the DNS-01 challenge — no need to open port 80.

HTTPS Wildcard com Let's Encrypt

Todo projeto precisa de HTTPS. Em vez de gerar um certificado por subdominio, usei um certificado wildcard cobrindo *.eduardolopes.dev. O plugin Cloudflare do Certbot cuida do desafio DNS-01 — sem precisar abrir a porta 80.

sudo certbot certonly \
    --dns-cloudflare \
    --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
    -d "example.dev" \
    -d "*.example.dev"

All Nginx configs share a single SSL snippet. Certbot auto-renews via a systemd timer. One certificate, all subdomains, zero maintenance.

Todas as configs do Nginx compartilham um unico snippet SSL. O Certbot renova automaticamente via timer do systemd. Um certificado, todos os subdominios, zero manutencao.

Monitoring with Prometheus & Grafana

If you can't see it, you can't fix it. The monitoring stack runs as Docker containers alongside the projects:

  • Prometheus — scrapes metrics every 15 seconds from Node Exporter and PM2
  • Node Exporter — exposes system metrics (CPU, RAM, disk, network, temperature)
  • PM2 Prometheus Exporter — exposes per-process metrics (memory, CPU, restarts)
  • Grafana — dashboards, available at grafana.example.dev

The entire stack uses about 500MB of RAM. On a Pi with 8GB, that's plenty. Grafana's "Node Exporter Full" dashboard (ID 1860) gives you a comprehensive view of the Pi's health — essential for catching issues before they become outages.

Monitoramento com Prometheus & Grafana

Se voce nao consegue ver, nao consegue consertar. O stack de monitoramento roda como containers Docker junto com os projetos:

  • Prometheus — coleta metricas a cada 15 segundos do Node Exporter e PM2
  • Node Exporter — expoe metricas do sistema (CPU, RAM, disco, rede, temperatura)
  • PM2 Prometheus Exporter — expoe metricas por processo (memoria, CPU, restarts)
  • Grafana — dashboards, disponivel em grafana.example.dev

O stack inteiro usa cerca de 500MB de RAM. Em um Pi com 8GB, e mais que suficiente. O dashboard "Node Exporter Full" do Grafana (ID 1860) da uma visao completa da saude do Pi — essencial para pegar problemas antes que virem quedas.

Deploying a Real Project

The first real test was migrating a production API from Fly.io to the Pi. This wasn't a simple Node app — it needed PostgreSQL with PostGIS extensions, Redis, and Prisma ORM migrations.

For projects with multiple services, the standard pi-deploy Docker flow (single docker run) wasn't enough. I created a custom docker-compose.prod.yml for the project:

Fazendo Deploy de um Projeto Real

O primeiro teste real foi migrar uma API de producao do Fly.io para o Pi. Nao era um app Node simples — precisava de PostgreSQL com extensoes PostGIS, Redis e migracoes do Prisma ORM.

Para projetos com multiplos servicos, o fluxo Docker padrao do pi-deploy (um unico docker run) nao era suficiente. Criei um docker-compose.prod.yml customizado para o projeto:

services:
  postgres:
    image: postgres:16-bookworm  # Not alpine — need apt for PostGIS
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./install-postgis.sh:/docker-entrypoint-initdb.d/00-postgis.sh
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]

  redis:
    image: redis:7-alpine

  api:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
    ports:
      - "4001:3000"
    env_file:
      - .env.production
    depends_on:
      postgres:
        condition: service_healthy

ARM64 Gotcha: PostGIS

The first deploy attempt failed. The popular postgis/postgis Docker image is amd64 only — no ARM64 build exists. The Pi runs ARM64 (aarch64), so the container crashed immediately.

The fix: use the official postgres:16-bookworm image (which is multi-arch) and install PostGIS via an init script that runs on first container start:

Pegadinha ARM64: PostGIS

A primeira tentativa de deploy falhou. A popular imagem Docker postgis/postgis e apenas amd64 — nao existe build ARM64. O Pi roda ARM64 (aarch64), entao o container crashou imediatamente.

A solucao: usar a imagem oficial postgres:16-bookworm (que e multi-arch) e instalar o PostGIS via um script de inicializacao que roda na primeira vez que o container inicia:

#!/bin/bash
# install-postgis.sh — runs once on first container start
apt-get update
apt-get install -y --no-install-recommends postgresql-16-postgis-3

After this fix, the post-receive hook builds the API image, starts all services, waits for Postgres to be healthy, runs Prisma migrations, and seeds the database — all automatically on git push raspi main.

Depois dessa correcao, o hook post-receive builda a imagem da API, inicia todos os servicos, espera o Postgres ficar saudavel, roda as migracoes do Prisma e popula o banco — tudo automaticamente no git push raspi main.

The Cost Comparison

Let's be honest about the numbers. I'm comparing the cost of running one API with a database and Redis — a typical side project stack.

A Comparacao de Custos

Vamos ser honestos com os numeros. Estou comparando o custo de rodar uma API com banco de dados e Redis — um stack tipico de side project.

Raspberry Pi Fly.io Heroku Railway
Compute $0/mo ~$5/mo $7/mo ~$5/mo
Database $0/mo $0-7/mo $9/mo ~$5/mo
Redis $0/mo $0-3/mo $15/mo ~$3/mo
SSL/HTTPS Free (Let's Encrypt) Included Included Included
Monitoring Free (Grafana) Basic $0-7/mo Basic
Domain ~$20/yr ~$20/yr ~$20/yr ~$20/yr
Monthly total ~$2/yr domain only $5-15/mo $31+/mo $13+/mo
Per year ~$20 $60-180 $372+ $156+

A few important caveats:

  • The Pi wasn't bought for this. I already had it. If you're buying one specifically for hosting, factor in ~$80 for the board + accessories. It pays for itself in a few months
  • Electricity is negligible. A Pi 5 draws about 5-12W under load. That's roughly $1-2/month in most countries
  • The Pi can run multiple projects. With 8GB RAM, I comfortably run the API + Postgres + Redis + Grafana stack + other PM2 apps simultaneously
  • Cloud services scale better. If your side project goes viral, Heroku/Fly.io can scale horizontally. A Pi cannot. But for personal projects, this is rarely the issue

Algumas ressalvas importantes:

  • O Pi nao foi comprado para isso. Eu ja tinha ele. Se voce for comprar um especificamente para hosting, considere ~R$400 pela placa + acessorios. Ele se paga em poucos meses
  • Eletricidade e desprezivel. Um Pi 5 consome cerca de 5-12W sob carga. Isso da aproximadamente R$5-10/mes no Brasil
  • O Pi roda multiplos projetos. Com 8GB de RAM, eu rodo confortavelmente a API + Postgres + Redis + stack do Grafana + outros apps PM2 simultaneamente
  • Servicos cloud escalam melhor. Se seu side project viralizar, Heroku/Fly.io escalam horizontalmente. Um Pi nao. Mas para projetos pessoais, isso raramente e o problema

Pros and Cons

Pros e Contras

Advantages

  • Zero monthly cost for compute
  • Full control over every layer
  • Massive learning experience
  • No vendor lock-in
  • Run unlimited projects
  • No cold starts or sleep timers
  • Your data stays on your hardware
  • Cloudflare Tunnel = free DDoS protection

Vantagens

  • Zero custo mensal para compute
  • Controle total sobre cada camada
  • Experiencia massiva de aprendizado
  • Sem vendor lock-in
  • Rode projetos ilimitados
  • Sem cold starts ou sleep timers
  • Seus dados ficam no seu hardware
  • Cloudflare Tunnel = protecao DDoS gratis

Drawbacks

  • You're the sysadmin (no support team)
  • Limited CPU/RAM (4 cores, 8GB)
  • Depends on your home internet
  • No horizontal scaling
  • SD card is a single point of failure
  • Initial setup takes time
  • ARM64 compatibility issues (PostGIS, etc.)
  • No geographic redundancy

Desvantagens

  • Voce e o sysadmin (sem time de suporte)
  • CPU/RAM limitados (4 cores, 8GB)
  • Depende da sua internet residencial
  • Sem escala horizontal
  • Cartao SD e ponto unico de falha
  • Setup inicial leva tempo
  • Problemas de compatibilidade ARM64 (PostGIS, etc.)
  • Sem redundancia geografica

Who Is This For?

This setup is perfect if you:

  • Run personal projects that don't need five-nines uptime
  • Want to deeply understand deployment infrastructure
  • Already own a Raspberry Pi (or want an excuse to buy one)
  • Are tired of paying $5-30/month for hobby projects that get 10 visitors a day
  • Want a playground to experiment with Docker, Nginx, monitoring, etc.

It's not for production services with SLAs, high-traffic apps, or anything that needs geographic distribution. For that, stick with cloud providers.

Para Quem e Isso?

Esse setup e perfeito se voce:

  • Roda projetos pessoais que nao precisam de cinco-noves de uptime
  • Quer entender profundamente a infraestrutura de deploy
  • Ja tem um Raspberry Pi (ou quer uma desculpa para comprar um)
  • Esta cansado de pagar R$25-150/mes por projetos hobby que recebem 10 visitas por dia
  • Quer um playground para experimentar com Docker, Nginx, monitoramento, etc.

Nao e para servicos de producao com SLAs, apps de alto trafego, ou qualquer coisa que precise de distribuicao geografica. Para isso, fique com provedores cloud.

Final Thoughts

What started as a weekend project to "put something on the Pi" turned into building a complete deployment platform. Along the way, I learned about CGNAT (the hard way), discovered that Cloudflare Tunnel is an incredible free tool, and gained a much deeper appreciation for what services like Heroku abstract away.

The website you're reading right now is served from that same Raspberry Pi, sitting quietly on my desk in Sao Paulo. It handles the requests, serves the HTML, terminates the TLS — all through a tiny ARM chip drawing less power than a light bulb.

There's something deeply satisfying about that.


The full stack: Raspberry Pi 5 (8GB) · Debian 13 · Node.js 22 · PM2 · Docker · Nginx · Cloudflare Tunnel · Certbot · Prometheus · Grafana · ~200 lines of bash (pi-deploy)

Total monthly cost: $0 (domain: ~$20/year)

Consideracoes Finais

O que comecou como um projeto de fim de semana para "colocar algo no Pi" se transformou na construcao de uma plataforma completa de deploy. No caminho, aprendi sobre CGNAT (da maneira dificil), descobri que o Cloudflare Tunnel e uma ferramenta gratuita incrivel, e ganhei uma apreciacao muito mais profunda pelo que servicos como Heroku abstraem.

O site que voce esta lendo agora e servido desse mesmo Raspberry Pi, sentado quieto na minha mesa em Sao Paulo. Ele processa as requisicoes, serve o HTML, termina o TLS — tudo atraves de um minusculo chip ARM consumindo menos energia que uma lampada.

Tem algo profundamente satisfatorio nisso.


O stack completo: Raspberry Pi 5 (8GB) · Debian 13 · Node.js 22 · PM2 · Docker · Nginx · Cloudflare Tunnel · Certbot · Prometheus · Grafana · ~200 linhas de bash (pi-deploy)

Custo mensal total: R$0 (dominio: ~R$100/ano)