Fazendo deploy de uma aplicação Express na DigitalOcean
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Você tem uma aplicação Express que funciona bem com node server.js no seu laptop, e uma conta na DigitalOcean. O que falta é tudo que fica entre esses dois fatos: um servidor que fica no ar depois que você fecha seu terminal, um banco Postgres de verdade em vez de um objeto em memória, um container que reinicia o processo se ele travar, e um certificado pra que a aplicação responda em https:// em vez de um IP puro. Nada disso é difícil por si só, mas cada peça tem seu próprio jeitinho de dar errado.
Este guia constrói tudo isso com Docker Compose: um container pro Postgres, um pra aplicação Express, e um pro Caddy cuidando do HTTPS. A aplicação em si é pequena de propósito: duas rotas, uma delas apoiada num pg Pool de verdade, então existe uma conexão de banco de dados real pra configurar e não só uma resposta estática. Ao final você vai ter um droplet servindo a aplicação no seu próprio domínio, com um jeito repetível de lançar novas versões e fazer backup do banco. Depois vamos ver o caminho mais curto: subir o mesmo repositório pro Railyard e deixar ele ler o package.json por você.
O que você vai precisar
- Um droplet da DigitalOcean. O plano Basic com 1 vCPU / 2 GB de RAM roda Postgres, o processo Node e Caddy juntos com conforto e custa atualmente US$12/mês; o plano de 1 GB (US$6/mês) funciona pra uma aplicação pequena sem muita carga concorrente, mas deixa pouca folga quando o Postgres também está rodando.
- Um nome de domínio, com acesso pra adicionar um registro
A. - Um repositório no GitHub (ou qualquer host Git) contendo seu projeto Express.
- Uma conta DigitalOcean com faturamento configurado, e uma chave SSH adicionada à sua conta.
- Docker instalado localmente se você quiser validar o arquivo do Compose e o Caddyfile antes de copiá-los pro servidor, que é o que este guia faz.
Passo 1: criar o droplet
No dashboard da DigitalOcean, crie um novo droplet: escolha a imagem Ubuntu 24.04 LTS, o plano Basic de 2 GB, uma região próxima dos seus usuários, e sua chave SSH. Quando ele estiver rodando, anote o IP público e aponte o registro A do seu domínio pra ele:
myapp.example.com. A 203.0.113.20
A propagação de DNS pode levar de alguns minutos a algumas horas. Você pode continuar configurando o servidor nesse meio tempo; o Caddy só precisa que o registro DNS esteja correto no momento em que ele tenta emitir um certificado.
Entre via SSH como root e instale o Docker:
ssh root@203.0.113.20
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Confira se funcionou:
docker compose version
Você deve ver uma string de versão do plugin do Compose, não um erro de “comando não encontrado”.
Passo 2: escrever a aplicação Express
O Express 5.3.0 é a versão atual no npm do Express 5, a major release atual, e o pg 8.23.1 é a versão atual no npm do node-postgres, o driver padrão pra falar com o Postgres a partir do Node sem um ORM. Inicialize o projeto e instale os dois:
npm init -y
npm install express@^5.3.0 pg@^8.23.1
O package.json deve terminar com estes campos, além do que o npm init já preencheu:
{
"name": "myapp",
"version": "1.0.0",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.3.0",
"pg": "^8.23.1"
}
}
Agora o server.js. Essa aplicação tem duas rotas: um health check simples, e uma que consulta o Postgres por um pool de conexões lendo DATABASE_URL do ambiente.
const express = require("express");
const { Pool } = require("pg");
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get("/", (req, res) => {
res.json({ status: "ok" });
});
app.get("/widgets", async (req, res) => {
const result = await pool.query(
"SELECT id, name, created_at FROM widgets ORDER BY id DESC LIMIT 20"
);
res.json(result.rows);
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "internal error" });
});
const port = process.env.PORT || 3000;
app.listen(port, "0.0.0.0", () => {
console.log(`listening on ${port}`);
});
Algumas coisas que vale notar. app.listen se vincula a 0.0.0.0, não ao localhost padrão, o que importa quando a aplicação roda dentro de um container: um processo escutando só em localhost dentro de um container é inalcançável de fora dele, mesmo com a porta publicada. A aplicação lê process.env.PORT, caindo pra 3000 no desenvolvimento local, mas a configuração do droplet abaixo define PORT explicitamente, então esse fallback nunca é de fato usado lá.
A rota /widgets não tem nenhum try/catch em volta da chamada await pool.query(...), e isso é intencional: o Express 5 mudou como ele trata erros lançados dentro de handlers de rota async. No Express 4, uma promise rejeitada dentro de um handler async tinha que ser capturada e passada pro next(err) manualmente, ou deixaria a requisição travada sem nenhuma resposta. O Express 5 faz isso automaticamente: uma promise rejeitada de um handler async é encaminhada pro middleware de tratamento de erros do mesmo jeito que um throw síncrono seria. É por isso que o tratador de erro no final deste arquivo (aquele com quatro argumentos) já basta pra capturar uma query que falhou sem envolver cada corpo de rota em try/catch.
Você também vai precisar de uma tabela pra essa query retornar alguma coisa. Depois que o Postgres estiver rodando (Passo 4 abaixo), crie ela:
docker compose exec -T db psql -U myapp -d myapp -c "CREATE TABLE widgets (id serial PRIMARY KEY, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());"
Passo 3: escrever o Dockerfile
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
node:24-alpine é uma release LTS atual do Node.js e uma imagem base pequena. O estágio deps roda npm ci --omit=dev só contra o lockfile, então a instalação de dependências fica cacheada separadamente de copiar o resto do código; uma mudança em server.js não força o npm ci a rodar de novo. O estágio runner copia o node_modules instalado do deps, depois o código da aplicação, e roda como node, o usuário não-root já presente na imagem oficial do Node, em vez de root. EXPOSE 3000 documenta a porta em que a aplicação escuta dentro do container; não publica nada por si só; isso é feito pelo Caddy e pelo Compose na próxima etapa.
Passo 4: escrever o docker-compose.yml
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
expose:
- "3000"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
volumes:
postgres_data:
caddy_data:
caddy_config:
Só o Caddy publica portas pro mundo externo; web e db ficam alcançáveis só por containers na mesma rede do Compose, o que é uma coisa menos exposta à internet por acidente. web depende do db estar saudável, não só iniciado, então a aplicação não tenta conectar antes do Postgres de fato estar aceitando conexões.
Eu validei esse arquivo localmente antes de usá-lo:
docker compose -f /tmp/compose-check/docker-compose.yml config
Ele foi lido sem nenhum aviso depois que POSTGRES_PASSWORD foi definido no ambiente (o Compose avisa, mas ainda resolve, quando uma variável interpolada como ${POSTGRES_PASSWORD} está indefinida no momento do config; no droplet ela vem do .env, configurado no Passo 6).
Passo 5: escrever o Caddyfile
myapp.example.com {
reverse_proxy web:3000
}
O Caddy solicita e renova um certificado Let’s Encrypt pra myapp.example.com automaticamente na primeira vez que inicia, desde que o DNS do domínio já aponte pra esse servidor e as portas 80 e 443 cheguem até ele. Tudo é redirecionado via proxy pro processo Express na porta 3000, a mesma porta que o Dockerfile expõe e que a aplicação escuta.
Eu validei esse Caddyfile com o próprio validador do Caddy antes de copiá-lo pro droplet:
docker run --rm -v /tmp/compose-check/Caddyfile:/etc/caddy/Caddyfile caddy:2 caddy validate --config /etc/caddy/Caddyfile
Ele imprimiu Valid configuration sem nenhum erro.
Passo 6: definir suas variáveis de ambiente e secrets
Crie um arquivo .env ao lado do docker-compose.yml, mantenha ele fora do controle de versão, e restrinja suas permissões:
POSTGRES_DB=myapp
POSTGRES_USER=myapp
POSTGRES_PASSWORD=a-long-random-password
DATABASE_URL=postgres://myapp:a-long-random-password@db:5432/myapp
PORT=3000
Gere a senha com openssl rand -base64 32 em vez de digitar algo memorável, e defina as permissões do arquivo pra que só o root consiga lê-lo:
chmod 600 .env
Note que DATABASE_URL aponta pra db, o nome do serviço no docker-compose.yml, não localhost. Containers na mesma rede do Compose se resolvem entre si pelo nome do serviço; localhost dentro do container web se refere ao próprio container web, que não está rodando o Postgres.
Passo 7: abrir o firewall e fazer o deploy
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
Depois, no diretório com seu docker-compose.yml:
docker compose up -d --build
docker compose logs -f caddy
Você deve ver o Caddy registrar que obteve um certificado pro seu domínio, e aí docker compose logs -f web deve mostrar listening on 3000 sem nenhum erro de conexão. Acesse https://myapp.example.com num navegador: você deve ver {"status":"ok"} da rota raiz, e https://myapp.example.com/widgets deve retornar um array vazio (ou as linhas que você inseriu no Passo 2) em vez de um 500.
Passo 8: backups e atualizações
Faça backup do Postgres com um dump agendado, escrito fora do container pra que um volume perdido não leve o backup junto:
docker compose exec -T db pg_dump -U myapp myapp > backup-$(date +%F).sql
Coloque isso num cron job e copie o resultado pra fora do droplet (DigitalOcean Spaces, S3, ou simplesmente scp pra outra máquina). Pra lançar uma versão nova, puxe seu código mais recente, reconstrua, e suba o container web de novo; o Postgres e o Caddy não precisam reiniciar:
git pull
docker compose up -d --build web
O caminho mais curto: fazer isso com o Railyard
Tudo acima (o Dockerfile, o usuário não-root, o DATABASE_URL, o proxy e o certificado do Caddy) é o que o Railyard configura automaticamente quando você aponta ele pro mesmo repositório. Pelas próprias regras de detecção dele: o gerenciador de pacotes é escolhido a partir de qual lockfile está presente no repositório (package-lock.json, pnpm-lock.yaml, yarn.lock, ou bun.lockb), e o script start no package.json roda como processo web quando o build termina; se o repositório já traz o próprio Dockerfile no lugar, esse é construído e rodado direto. O container escuta na $PORT que o Railyard atribui, a mesma variável de ambiente que o server.js deste guia já lê. Um script npm run worker, se existir no package.json, é oferecido como um processo worker em segundo plano junto do web. Uma dependência pg no package.json já é suficiente pro Railyard provisionar um banco Postgres gerenciado automaticamente, com DATABASE_URL injetado como variável de ambiente, sem addon pra adicionar à mão e sem precisar de Dockerfile pra um repositório que não traz um. Veja os docs de Node.js pras regras exatas de detecção.
Pra fazer deploy desse jeito: crie um droplet ou servidor pelo Railyard (a DigitalOcean é um dos provedores suportados), depois cole a URL Git do seu repositório, ou adicione isto ao seu README pra um clique:
[](https://app.railyard.run/deploy?repo=https://github.com/you/your-express-app)
O plano Free do Railyard cobre um servidor mais um servidor de banco de dados e três aplicações, sem cartão necessário. O Pro é US$19/mês por equipe, e o Team é US$49/mês por equipe pra limites mais altos e mais colaboradores.
O caminho manual acima ainda é o certo pra conhecer. É o que o Railyard está fazendo por debaixo dos panos, e entender isso significa que você nunca fica travado se algo precisar de uma mudança que o dashboard ainda não expõe.
Resolução de problemas
“Port is already allocated” ao rodar docker compose up. Alguma outra coisa já está vinculada à porta 80 ou 443, geralmente um nginx padrão instalado por uma imagem de um clique da DigitalOcean. Pare e desative ele: systemctl stop nginx && systemctl disable nginx, depois tente de novo.
Conexão com o Postgres recusada, ou ECONNREFUSED nos logs da aplicação. Cheque o DATABASE_URL no .env: ele precisa apontar pra db, o nome do serviço no Compose, não localhost. localhost dentro do container web significa o próprio container web, que não tem Postgres rodando nele.
Permissão negada escrevendo um arquivo, ou o container encerra imediatamente com um erro EACCES. O container roda como o usuário não-root node do Passo 3. Se sua aplicação escreve num diretório que ela não possui (um arquivo de log, uma pasta de upload embutida na imagem), ou crie esse diretório com a propriedade certa no Dockerfile antes de trocar pro USER node, ou escreva num volume montado no lugar.
HTTPS nunca sobe, ou o Caddy registra erros de certificado. Cheque se o registro A do domínio realmente resolve pro droplet (dig myapp.example.com) e se as portas 80 e 443 estão abertas tanto no firewall do droplet quanto no firewall de nuvem da DigitalOcean, se você adicionou um. O Let’s Encrypt valida via HTTP puro primeiro, então a porta 80 precisa estar acessível mesmo que o site final seja só HTTPS.
A aplicação é morta ou o droplet parece lento sob tráfego real. Num droplet de 1 GB, Postgres, o processo Node e o Caddy estão todos competindo pela mesma memória, e um processo Node sob carga concorrente pode crescer o heap rapidamente. Observe docker stats sob carga, adicione um arquivo de swap como rede de segurança, ou suba pro droplet de 2 GB se o tráfego for real.
Perguntas frequentes
Eu preciso de Docker, ou posso rodar isso com um gerenciador de processos e uma instalação de Node no sistema? O Docker Compose mantém o Postgres, a aplicação e o Caddy isolados e fáceis de mover entre servidores, mas pm2 ou systemd rodando node server.js contra um Postgres instalado no sistema funcionam do mesmo jeito; a chamada app.listen e a configuração do Caddy acima não mudam de nenhum dos dois jeitos.
O que realmente mudou entre o Express 4 e o 5 que importa aqui? A mudança relevante pra este guia é o encaminhamento automático de promises rejeitadas de handlers de rota async pro middleware de tratamento de erros, que é por isso que /widgets acima não tem try/catch em volta do await. O Express 5 também removeu algumas APIs depreciadas há muito tempo e subiu o suporte mínimo de versão do Node.js, mas este guia não depende de mais nada que mudou.
Eu posso usar um ORM como Prisma ou Sequelize em vez do pg puro? Sim. Os dois leem DATABASE_URL do mesmo jeito que o pg.Pool faz aqui, e o Dockerfile, o arquivo do Compose e o Caddyfile não mudam; só o server.js e a dependência no package.json mudam. Este guia usa pg puro pra manter o exemplo pequeno e evitar uma etapa de build pra um arquivo de schema.
Como eu adiciono um worker em segundo plano? Adicione um serviço worker ao docker-compose.yml, construído a partir da mesma imagem e arquivo .env, com um command que roda algo diferente de server.js, por exemplo node worker.js. Ele pode usar o mesmo pg.Pool e DATABASE_URL que o processo web, ou adicionar Redis como outro container se a fila de jobs precisar de um.
O droplet de 1 GB é realmente suficiente? Pra uma aplicação pequena sem muito tráfego concorrente, sim, o nível de US$6/mês funciona bem. Uma vez que Postgres, o processo Node e o Caddy estão todos rodando juntos sob carga real, o plano de 2 GB a US$12/mês dá folga suficiente pra que um pico de tráfego não vire uma morte por falta de memória.
Uma aplicação Express num droplet não é frágil depois que as peças estão conectadas corretamente, e a maior parte do que está aqui não é específico do Express: o mesmo container do Postgres, o mesmo proxy do Caddy e o mesmo cron job de backup funcionam pra próxima aplicação Node que você colocar nesse servidor também.
Por que escolhem o Railyard para isso
- Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
- Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
- Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
- Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
- Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.