Docker Compose can run a production stack on one server, but a successful docker compose up -d is only the start. You still own the operating system, firewall, domain, certificates, updates, data and recovery plan. This guide assumes you already have a repository with a Dockerfile and an application that binds to 0.0.0.0:8080 inside the container and accepts the database variables shown below. Check your framework’s bind address: if it listens only on 127.0.0.1 or localhost, Caddy cannot reach it from another container. It is a deployment pattern to adapt, not a one-click installer or a claim that every app fits every VPS.
If your project is a static build with no long-running process, compare it with Hostbrid Projects first. A VPS is the better fit when you need system-level control and are willing to manage the machine. Hostbrid sells VPS plans; you administer Docker and the software yourself. Review current sizes and availability on the VPS page before choosing capacity.
Before ordering, follow the guide to measuring RAM, CPU and disk for a Docker app; this page focuses on deploying the stack and protecting its services.
For an n8n deployment, see the n8n VPS guide, which covers its quick-install and Docker Compose paths.
Separate the public edge from application and database traffic
The browser should reach a reverse proxy, not a database port. In this example Caddy accepts web traffic and forwards it to the app; PostgreSQL has no published host port. Compose gives services DNS names on their shared network, so the app can reach db:5432 without exposing PostgreSQL to the internet. Docker documents service networking and production-specific Compose configuration in its network overview and production guide.
Create a compose.yaml beside your app’s Dockerfile and a Caddyfile as shown. Replace app configuration with the exact variables your application supports. The app image must actually read DB_HOST, DB_NAME, DB_USER and DB_PASSWORD; Compose cannot make an application database-aware by itself.
services:
app:
build: .
restart: unless-stopped
environment:
PORT: "8080"
DB_HOST: db
DB_PORT: "5432"
DB_NAME: ${DB_NAME:?set DB_NAME in .env}
DB_USER: ${DB_USER:?set DB_USER in .env}
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
expose: ["8080"]
networks: [edge, data]
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:?set DB_NAME in .env}
POSTGRES_USER: ${DB_USER:?set DB_USER in .env}
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
volumes: [postgres_data:/var/lib/postgresql/data]
networks: [data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
caddy:
image: caddy:2
restart: unless-stopped
environment:
SITE_DOMAIN: ${SITE_DOMAIN:?set SITE_DOMAIN in .env}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks: [edge]
depends_on: [app]
networks:
edge: {}
data:
internal: true
volumes:
postgres_data: {}
caddy_data: {}
caddy_config: {}
{$SITE_DOMAIN} {
encode zstd gzip
reverse_proxy app:8080
}
The data network is marked internal and only the app and database join it. The app also joins edge so Caddy can reach its port; PostgreSQL joins neither the public host interface nor Caddy’s network. expose documents the app port for the Compose stack; it does not publish a host port. Only Caddy maps 80 and 443. Keep the database volume: containers are replaceable, data is not. Docker explains that named volumes persist separately from containers, but a volume on the same VPS is not a backup.
Prepare the VPS and domain
Choose a supported Linux image, update packages, create a non-root administrator with SSH keys, and install Docker Engine and the Compose plugin from Docker’s official instructions for that distribution. Keep SSH access working in a second session before tightening the firewall. Restrict SSH to trusted addresses where practical; allow the web ports only if the site must be public. Docker warns that published container ports can interact with host firewall rules in ways operators may not expect, so verify the behavior for the server’s firewall and Docker version rather than assuming a UFW rule alone blocks every published port. Read Docker’s port publishing guidance.
Point the domain’s A record to the VPS IPv4 address and add an AAAA record only if IPv6 is actually routed and configured. Wait for DNS to resolve from outside your network. Caddy’s automatic HTTPS needs a public domain and reachable validation ports; open TCP 80/443 on both any provider firewall and the host firewall. Do not publish PostgreSQL’s 5432 port for convenience. If remote database access is necessary, use a private network or authenticated tunnel and a separate access policy.
Create a private .env file with SITE_DOMAIN, DB_NAME, DB_USER and a generated strong DB_PASSWORD. Set restrictive permissions, exclude it from Git, and add .env to .dockerignore because build: . sends a build context to the builder. Never paste credentials into support tickets or screenshots. A Compose .env file is configuration, not a managed secret vault; users with root or Docker access can inspect container settings. Rotate credentials if they leak. Confirm your app uses the database hostname db, not localhost: inside the app container, localhost means that same container.
Validate before and after starting
From the directory containing the files, run:
docker compose config -q
docker compose up -d --build
docker compose ps
docker compose logs --tail=100 app db caddy
config -q catches Compose parsing and required-variable errors; it does not prove the app works. Check that the database becomes healthy, the app logs show a successful database connection, and Caddy can obtain a certificate. From another network, request the HTTPS domain and test the actual login, form, uploads and critical user journey. Also confirm that ports 80 and 443 are reachable while 5432 is not. A green container status does not establish that the service is correct or recoverable.
Update with a rollback and recovery path
Commit Compose files and application code to a private repository, but keep .env and real data out of Git. Record the currently deployed image or commit before changing it. Pull or build the new version, validate the merged production configuration, and update deliberately. Docker’s production guide describes production overrides and targeted service recreation. For a risky release, test the upgrade against a restored copy of the data first. A rollback of code may not reverse a database migration; plan the schema change separately.
Back up the database with a database-aware dump and copy it off the VPS, then test a restore into an isolated environment. Back up uploaded files, configuration and the Caddy data directory when their contents matter. A snapshot or copied live database volume may be inconsistent; follow the database vendor’s backup procedure. Set a retention policy and encrypt off-site copies. Rebuild or recreate containers only after confirming that persistent data is outside the container writable layer. Never use docker compose down -v as routine cleanup: -v can remove named volumes and their data.
This setup is a starting point, not managed operations: it does not provide automatic Hostbrid Docker deployment, monitoring, patching, backup service, uptime guarantee or a capacity recommendation. For a server you will administer yourself, compare the current Hostbrid VPS plans; for advice about fit, use contact. The matching Node and Docker diagnostic guide covers one process’s port and health checks, not this multi-service deployment pattern.