Conteúdo

Tutorial de implantação com Docker: publique um site em um servidor na nuvem (VPS)

Nesta seção, vamos reunir o que você aprendeu até aqui: implantar em um servidor Linux na nuvem (VPS) um site gerenciado com Docker Compose.

As saídas desta seção simulam o fluxo completo em uma única máquina: usamos o registry:3 local no lugar do Docker Hub ou do GitHub Container Registry (localhost:5000) e um certificado local emitido pelo Caddy para localhost no lugar do certificado de um domínio real. Em uma implantação real, basta substituir os nomes das imagens e o domínio pelos seus.

Fluxo geral

Local ou CI ──docker build / push──▶ Registry ──docker compose pull──▶ Servidor na nuvem

O código é transformado em imagem por um build local ou no CI e enviado ao Registry; o servidor só baixa a imagem e a executa, sem fazer o build no servidor. Assim, o servidor não precisa do código-fonte nem das ferramentas de build, e o resultado de cada implantação é idêntico. Para voltar a uma versão anterior, basta trocar pela tag antiga.

1. Preparar o servidor

Pode ser um servidor Linux de qualquer provedor de nuvem. Vamos usar o Ubuntu como exemplo:

  1. Conecte-se via SSH, crie um usuário comum e desative o login por senha (use somente chaves).

  2. Siga as instruções para Linux em Instalar Docker e instale o Docker Engine e o plugin do Compose.

  3. No firewall, libere somente as portas 22 (SSH), 80 e 443:

    sudo ufw allow OpenSSH
    sudo ufw allow 80,443/tcp
    sudo ufw enable

    Atenção: as portas abertas com -p ignoram o ufw (veja Boas práticas de segurança); por isso, no compose.yaml abaixo, somente o proxy reverso tem ports.

  4. Configure a inicialização automática do Docker: sudo systemctl enable docker.

2. Aplicação

O exemplo é um site pequeno com Node.js + PostgreSQL. A cada solicitação, ele registra uma visita e retorna a versão atual e o número de visitas.

myapp/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
└── .dockerignore

server.js:

const express = require("express");
const { Pool } = require("pg");

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const version = process.env.APP_VERSION || "dev";

app.get("/", async (req, res) => {
  await pool.query("CREATE TABLE IF NOT EXISTS visits (id serial PRIMARY KEY, at timestamptz DEFAULT now())");
  await pool.query("INSERT INTO visits DEFAULT VALUES");
  const { rows } = await pool.query("SELECT count(*)::int AS visits FROM visits");
  res.json({ app: "myapp", version, visits: rows[0].visits });
});

const server = app.listen(3000, () => console.log(`myapp ${version} listening on port 3000`));
process.on("SIGTERM", () => server.close(() => pool.end().then(() => process.exit(0))));

As dependências de package.json são express e pg. O Dockerfile (veja a explicação sobre a configuração em Aplicação Node.js) recebe o número da versão por meio de ARG e o grava em ENV para que o programa possa exibir sua própria versão:

FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
USER node
EXPOSE 3000
CMD ["node", "server.js"]

3. Construa e envie a imagem

Na sua máquina ou no CI, use um número de versão fixo como tag:

$ docker build --build-arg APP_VERSION=1.0.0 -t localhost:5000/myapp:1.0.0 .

[+] Building 6.7s (10/10) FINISHED                                                                                                     docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 308B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                                                                                0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 63B                                                                                                                 0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          2.4s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.0s
 => => extracting sha256:e2de96513ba9eb53b431787ec8a65cdde380ac4772a3e4c4b714dcfde2a102b5                                                        0.1s
 => => extracting sha256:3c504d5a797be28f9de43147d49cd8ecb2eea46dfaf7beb8715db695d5f0a7f0                                                        2.1s
 => => extracting sha256:02f40d53b06f6b91fec0cd6a6719656da99270bff8ee54ed8dcdf4c157763fc1                                                        0.1s
 => => extracting sha256:7ce7dcbafb7f750cf0c6be3c30e34372b06dd1182fa67f6ae30782c8150c5444                                                        0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 36.48kB                                                                                                             0.0s
 => [2/5] WORKDIR /app                                                                                                                           0.1s
 => [3/5] COPY package.json package-lock.json ./                                                                                                 0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                                                                                       3.5s
 => [5/5] COPY --chown=node:node . .                                                                                                             0.1s
 => exporting to image                                                                                                                           0.6s
 => => exporting layers                                                                                                                          0.3s
 => => exporting manifest sha256:a0bd86a3b62021adb05a5e710c5a59f9e6287c8c84016ba22f1923bc4aaeeabf                                                0.0s
 => => exporting config sha256:7f75124e75541f256217e31a844e8c9b687434955d053e47e5dae571aa3944c6                                                  0.0s
 => => exporting attestation manifest sha256:9d6688e6e287f9a0b3d3a95d23765cde8496c89bf6dd0c7fd7a191ac58abe9db                                    0.0s
 => => exporting manifest list sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522                                           0.0s
 => => naming to localhost:5000/myapp:1.0.0                                                                                                      0.0s
 => => unpacking to localhost:5000/myapp:1.0.0                                                                                                   0.3s

$ docker push localhost:5000/myapp:1.0.0
The push refers to repository [localhost:5000/myapp]
44136fa355b3: Pushed
2297ea3ee753: Pushed
7ce7dcbafb7f: Pushed
fffb74b09a22: Pushed
e2de96513ba9: Pushed
1d73dbdb2af1: Pushed
bd9735416a50: Pushed
02f40d53b06f: Pushed
22ac32835411: Pushed
3c504d5a797b: Pushed
1.0.0: digest: sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522 size: 856

Na prática, o nome da imagem é o endereço do Registry seguido da sua conta, por exemplo, yourname/myapp:1.0.0 no Docker Hub ou ghcr.io/yourname/myapp:1.0.0 no GitHub. Antes disso, execute docker login (consulte Envie para o Docker Hub).

Ao construir em um Mac com Apple Silicon e implantar em um host amd64 comum, lembre-se de adicionar --platform linux/amd64. Caso contrário, o servidor exibirá exec format error. Consulte Imagens multiplataforma.

4. Arquivos no servidor

No servidor, crie uma pasta, por exemplo /srv/myapp. Você só precisa de três arquivos, não precisa do código-fonte:

/srv/myapp/
├── compose.yaml
├── .env
└── Caddyfile

compose.yaml:

services:
  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app
    restart: unless-stopped
    logging:
      options: { max-size: "10m", max-file: "3" }

  app:
    image: localhost:5000/myapp:${APP_VERSION}
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    logging:
      options: { max-size: "10m", max-file: "3" }

  db:
    image: postgres:18
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
      interval: 5s
      retries: 10
    restart: unless-stopped
    logging:
      options: { max-size: "10m", max-file: "3" }

volumes:
  caddy_data:
  caddy_config:
  pgdata:

.env:

APP_VERSION=1.0.0
DB_PASSWORD=change-this-to-a-long-random-password

Caddyfile (na implantação real, substitua localhost pelo seu domínio, por exemplo example.com):

localhost {
    reverse_proxy app:3000
}

Pontos principais:

ConfiguraçãoDescrição
image: ...:${APP_VERSION}A versão é controlada por .env; para atualizar ou reverter, basta alterar esta linha
Somente caddy tem portsNão é possível acessar o aplicativo nem o banco de dados externamente
CaddySolicita e renova certificados HTTPS automaticamente e redireciona HTTP para HTTPS. Consulte a seção final de Nginx e proxy reverso
pgdata:/var/lib/postgresqlCaminho de montagem do PostgreSQL 18. Consulte PostgreSQL
pg_isready -h 127.0.0.1Evita que o banco de dados seja considerado healthy durante a inicialização
restart: unless-stoppedInicia automaticamente após uma falha do aplicativo ou a reinicialização do host
loggingLimita o tamanho dos logs para evitar que encham o disco. Consulte Liberar espaço em disco

O arquivo .env contém uma senha. Defina as permissões: chmod 600 .env.

5. Inicie

$ ls -a
.
..
.env
Caddyfile
compose.yaml

$ docker compose pull
[+] pull 35/35
 ✔ Image caddy:2                    Pulled                                                                                                        2.8s
 ✔ Image localhost:5000/myapp:1.0.0 Pulled                                                                                                        0.3s
 ✔ Image postgres:18                Pulled                                                                                                       12.2s

$ docker compose up -d
[+] up 7/7
 ✔ Network srv-myapp_default     Created                                                                                                          0.0s
 ✔ Volume srv-myapp_caddy_config Created                                                                                                          0.0s
 ✔ Volume srv-myapp_caddy_data   Created                                                                                                          0.0s
 ✔ Volume srv-myapp_pgdata       Created                                                                                                          0.0s
 ✔ Container srv-myapp-db-1      Healthy                                                                                                          6.0s
 ✔ Container srv-myapp-app-1     Started                                                                                                          6.2s
 ✔ Container srv-myapp-caddy-1   Started                                                                                                          6.7s

$ docker compose ps
NAME                IMAGE                        COMMAND                  SERVICE   CREATED          STATUS                   PORTS
srv-myapp-app-1     localhost:5000/myapp:1.0.0   "docker-entrypoint.s…"   app       10 seconds ago   Up 4 seconds             3000/tcp
srv-myapp-caddy-1   caddy:2                      "caddy run --config …"   caddy     10 seconds ago   Up 3 seconds             0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 443/udp, 2019/tcp
srv-myapp-db-1      postgres:18                  "docker-entrypoint.s…"   db        10 seconds ago   Up 9 seconds (healthy)   5432/tcp

$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":1}

$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":2}

$ docker compose logs app
app-1  | myapp 1.0.0 listening on port 3000
  • docker compose pull baixa primeiro todas as imagens, incluindo myapp:1.0.0, que enviamos.
  • up -d inicia os serviços na ordem das dependências: app só inicia depois que db fica Healthy; por último, inicia caddy.
  • Em ps, somente caddy expõe as portas 80 e 443 externamente.
  • Faça duas solicitações por HTTPS. visits passa de 1 para 2, e os dados são gravados no PostgreSQL. (-k é usado porque aqui estamos usando um certificado local; em um domínio de produção, o certificado do Let's Encrypt dispensa -k.)

Na implantação em produção, acesse https://seu-dominio para confirmar que o site está funcionando e use docker compose logs -f app para acompanhar os logs.

6. Atualizar a versão

Construa a imagem localmente e envie a nova versão:

$ docker build -q --build-arg APP_VERSION=1.1.0 -t localhost:5000/myapp:1.1.0 .
sha256:aac108fcd63549926e767b781e0b9aa25a86e2f2cd449710a41b5e8d0accda16

$ docker push -q localhost:5000/myapp:1.1.0
localhost:5000/myapp:1.1.0

No servidor, altere APP_VERSION em .env e, em seguida, baixe e recrie somente app:

$ grep APP_VERSION .env
APP_VERSION=1.1.0

$ docker compose pull app
[+] pull 1/1
 ✔ Image localhost:5000/myapp:1.1.0 Pulled                                                                                                        0.1s

$ docker compose up -d app
[+] up 2/2
 ✔ Container srv-myapp-db-1  Healthy                                                                                                              0.7s
 ✔ Container srv-myapp-app-1 Started                                                                                                              1.0s

$ curl -sk https://localhost/
{"app":"myapp","version":"1.1.0","visits":3}

$ docker compose images app
CONTAINER           REPOSITORY             TAG                 PLATFORM            IMAGE ID            SIZE                CREATED
srv-myapp-app-1     localhost:5000/myapp   1.1.0               linux/amd64         aac108fcd635        251MB               44 seconds ago
  • pull app baixa somente a nova imagem de app. (Como estamos simulando tudo na mesma máquina, a imagem já está localmente, então levou apenas 0,1 segundo.)
  • up -d app detecta que a imagem mudou e recria somente o contêiner app; db e caddy não são afetados, e o site fica fora do ar por cerca de 1 segundo.
  • A resposta mostra version como 1.1.0; visits continua aumentando a partir de 3, e os dados do banco de dados são preservados.
  • docker compose images app confirma que a versão 1.1.0 está em uso.

Reverter a versão

Se a nova versão apresentar problemas, altere APP_VERSION de volta para 1.0.0 e execute up -d app:

$ grep APP_VERSION .env
APP_VERSION=1.0.0

$ docker compose up -d app
[+] up 2/2
 ✔ Container srv-myapp-db-1  Healthy                                                                                                              0.7s
 ✔ Container srv-myapp-app-1 Started                                                                                                              0.9s

$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":4}

Um único comando restaura a versão anterior, e a imagem antiga ainda está no servidor, então não é preciso baixá-la novamente. Por isso você deve usar números de versão fixos em vez de latest: com latest, você não sabe ao certo qual versão está em execução no servidor e não consegue voltar explicitamente para a versão anterior.

Alterações na estrutura do banco de dados (migration) geralmente não podem ser revertidas automaticamente. Faça backup do banco de dados antes de colocar uma nova versão em produção, e prefira que as alterações no banco de dados da nova versão sejam compatíveis com a versão anterior do programa.

7. Backup

No mínimo, faça backup do banco de dados todos os dias e salve o arquivo em outra máquina. Crie backup.sh em /srv/myapp:

#!/bin/sh
set -e
cd "$(dirname "$0")"
mkdir -p backup
docker compose exec -T db pg_dump -U app app | gzip > backup/app-$(date +%F).sql.gz
find backup -name 'app-*.sql.gz' -mtime +14 -delete
$ ./backup.sh && ls -lh backup
total 4K
-rw-r--r--    1 root     root         872 Oct 13 01:17 app-2026-10-13.sql.gz

$ gunzip -c backup/app-*.sql.gz | grep -A 6 "^COPY public.visits"
COPY public.visits (id, at) FROM stdin;
1	2026-10-13 01:16:44.277875+00
2	2026-10-13 01:16:44.390504+00
3	2026-10-13 01:16:57.763022+00
4	2026-10-13 01:17:02.44854+00
\.
  • O arquivo de backup recebe a data no nome, e find ... -mtime +14 -delete remove os backups com mais de 14 dias.
  • Confira o conteúdo após descompactar: os 4 registros da tabela visits estão lá.

Configure chmod +x backup.sh e use crontab -e para executá-lo todos os dias às 3h da manhã:

0 3 * * * /srv/myapp/backup.sh

Se o backup ficar na mesma máquina que o servidor, ele será perdido junto com o servidor em caso de falha. Portanto, use ferramentas como rsync ou rclone para copiar backup/ para outra máquina ou para um armazenamento na nuvem. Consulte Backup e restauração do PostgreSQL para ver como restaurar e teste a restauração periodicamente.

8. Automação (opcional)

Atualizar entrando manualmente por SSH logo se torna trabalhoso. Algumas formas comuns de automatizar:

  • CI/CD: construa e envie a imagem em um CI, como GitHub Actions, e depois use SSH para alterar APP_VERSION no servidor e executar docker compose pull app && docker compose up -d app.

  • Docker context: opere o Docker do servidor remoto diretamente da sua máquina, sem precisar entrar primeiro no servidor:

    docker context create prod --docker "host=ssh://user@your-server"
    docker --context prod ps

    docker --context prod compose up -d usa o compose.yaml do diretório local e executa o comando no servidor remoto.

Lista de verificação antes de colocar em produção

  • O SSH permite apenas login com chave, e o firewall libera somente as portas 22, 80 e 443
  • Somente o proxy reverso tem ports; o banco de dados não está exposto externamente
  • As tags das imagens usam números de versão fixos, sem latest
  • Todos os serviços têm restart: unless-stopped
  • O limite de tamanho dos logs está configurado
  • A verificação de integridade está escrita corretamente (pg_isready -h 127.0.0.1)
  • As permissões de .env são 600, e o arquivo não foi enviado ao Git
  • O banco de dados recebe backups automáticos diários em outro local, e a restauração foi testada na prática
  • Imagens antigas são removidas regularmente (consulte Liberar espaço em disco), e as imagens são reconstruídas periodicamente para receber atualizações de segurança (consulte Melhores práticas de segurança)

Resumo

  • Construa a imagem localmente ou no CI, envie-a para um Registry, e o servidor só precisa baixá-la e executá-la.
  • Mantenha o número da versão em .env; para atualizar ou reverter, basta alterar uma linha e executar up -d.
  • Somente o proxy reverso deve ficar exposto, e a verificação de integridade do banco de dados precisa estar correta.
  • Faça backups automáticos diários em outra máquina e teste a restauração periodicamente.

Exercícios

Exercício 1: Depois de reverter APP_VERSION no servidor de 1.1.0 para 1.0.0, os dados gravados no banco de dados pela nova versão continuam lá?

Resposta

Sim. A reversão troca somente a imagem de app; o Volume do banco de dados não muda. Neste capítulo, vimos que visits continuou aumentando. Porém, se a nova versão tiver alterado a estrutura das tabelas, o programa antigo talvez não consiga usar a nova estrutura. Por isso, faça um backup antes de colocar a nova versão em produção.

Perguntas frequentes

Preciso usar um Registry? Não posso executar docker build no servidor?

Pode, mas o servidor precisará do código-fonte e das ferramentas de build, e cada implantação poderá gerar um resultado diferente. Com a implantação por Registry, o servidor só precisa baixar imagens que já foram testadas, e o rollback também fica simples.

Que tipo de servidor na nuvem devo usar?

Qualquer servidor Linux que permita instalar o Docker Engine serve. Para um site pequeno, normalmente 1 a 2 GB de memória bastam; aumente esse valor se o banco de dados for maior.

É possível automatizar a implantação?

Sim. Use o GitHub Actions para fazer o build e o envio ao criar uma tag de versão e, em seguida, conectar-se ao servidor via SSH para atualizá-lo. Veja [Build automático com GitHub Actions](/pt/docker/ci-cd/).

Atualizado 14 de outubro de 2026