RE:NODE
ჰოსტინგი

VDS13 წუთის საკითხავი

Docker Compose for small stacks: one file, three services

Run a proxy, an app and a database from one compose file: networks, volumes, env files, healthchecks, updates and rollbacks on a single server.

ეს სტატია ჯერ ინგლისურადაა. ვთარგმნით.

0 მკითხველი

Compose is a text file that describes several containers and the wiring between them, and a command that makes reality match the file. On one server that is worth more than it sounds. Without it, a three-container stack is three docker run lines with twenty flags between them, remembered wrongly six weeks later. With it, the whole thing is docker compose up -d, the configuration is a file you can keep in git, and rebuilding the stack on a new machine is a git clone and one command.

What Compose is not is a scheduler. It will not move a container to another host, it will not restart something that has gone unhealthy, and it has no opinion about downtime. On a single VDS none of that is a loss, because there is only one host and the alternative was a shell script. This post builds a realistic stack - a reverse proxy, an application and a PostgreSQL database - and then covers the parts that decide whether it survives a year: where the data is, what down deletes, how secrets get in, and what to do when it will not come up.

What Compose is, and the file it reads#

Compose v2 is a plugin, invoked as docker compose with a space. The old Python docker-compose with a hyphen is a separate, retired program; if a tutorial uses the hyphen it predates the current tooling, though the file format is largely the same.

Compose looks for, in order: compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml. The first name is the current convention and the last is the one everybody has. Either works. What no longer works usefully is the version: "3.8" line at the top of old files - it is obsolete in Compose v2, it is ignored, and it produces a warning. Delete it.

One concept to grasp before the file makes sense: the project. Compose namespaces everything it creates by project name, which defaults to the directory name. A compose.yaml in /srv/notes produces containers called notes-app-1 and notes-db-1, a network called notes_default and a volume called notes_dbdata. Two projects in two directories do not collide. This is also why moving the directory, or renaming it, can suddenly make Compose think it is looking at a brand new stack with no volumes - pin the name with a top-level name: key if the directory is likely to move.

One file, three services#

Here is a stack that actually works: Caddy in front for TLS, a Node application, and PostgreSQL behind both.

compose.yaml
name: notesservices:  proxy:    image: caddy:2    restart: unless-stopped    ports:      - "80:80"      - "443:443"    volumes:      - ./Caddyfile:/etc/caddy/Caddyfile:ro      - caddy_data:/data      - caddy_config:/config    depends_on:      - app  app:    image: ghcr.io/example/notes:1.4.2    restart: unless-stopped    env_file:      - app.env    environment:      DATABASE_URL: postgres://notes:${DB_PASSWORD}@db:5432/notes      NODE_ENV: production    depends_on:      db:        condition: service_healthy  db:    image: postgres:17    restart: unless-stopped    environment:      POSTGRES_USER: notes      POSTGRES_PASSWORD: ${DB_PASSWORD}      POSTGRES_DB: notes    volumes:      - dbdata:/var/lib/postgresql/data    healthcheck:      test: ["CMD-SHELL", "pg_isready -U notes -d notes"]      interval: 10s      timeout: 5s      retries: 5      start_period: 30svolumes:  dbdata:  caddy_data:  caddy_config:

And the proxy configuration it mounts, which is three lines because Caddy obtains and renews the certificate itself once the DNS name points at the machine:

Caddyfile
notes.example.com {    encode gzip    reverse_proxy app:3000}

Notice what is absent. The application has no ports: entry and neither does the database. Neither of them is reachable from outside the machine at all. Only the proxy publishes anything, and it publishes the two ports the internet already expects.

TLS on 443http://app:3000TCP 5432Internetports 80 and 443Caddypublishes 80 and 443Node applistens on :3000PostgreSQLvolume dbdata
One public door in front of two private services

The network, and why only the proxy publishes a port#

Compose creates one network per project and attaches every service to it. On that network, Docker's embedded DNS resolves each service name to its container, so app reaches the database at the hostname db on port 5432 with no configuration at all, and the proxy reaches the application at app:3000. Nothing needs to know an IP address, and the addresses change on every recreate anyway.

The important consequence: ports between services on the same Compose network are already open. ports: is not about letting containers talk to one another; it is about punching a hole from the host into a container. Every ports: entry you add is a new public listener, so add them only for the front door.

This also solves a problem that catches people the first time. A published port bypasses UFW - ports: - "5432:5432" on the database makes PostgreSQL reachable from the internet even with a default-deny firewall, because the packet is forwarded to the container instead of delivered to the host. The full mechanism is in the UFW firewall guide and the shorter version is in Docker on a VDS. If you must publish a port for debugging, publish it to loopback - "127.0.0.1:5432:5432" - and reach it over an SSH tunnel.

If you prefer nginx to Caddy, the same shape applies with a longer configuration file, including the header lines a proxied application needs to see the real client. The nginx reverse proxy guide has that server block, and what a reverse proxy does explains why X-Forwarded-For exists at all.

Environment variables and the two kinds of env file#

This confuses nearly everyone, because there are two mechanisms with similar names doing different jobs.

The `.env` file in the project directory is read by Compose itself, before anything starts, and its values substitute ${VAR} placeholders inside compose.yaml. It configures the file, not the containers. If .env contains DB_PASSWORD=hunter2, then ${DB_PASSWORD} in the compose file becomes that value.

`env_file:` on a service hands a file of KEY=value lines to that container as environment variables. Compose never looks inside it. This is where application configuration belongs.

.env - read by Compose, substituted into compose.yaml
DB_PASSWORD=a-long-random-stringAPP_TAG=1.4.2
app.env - handed to the app container
SESSION_SECRET=another-long-random-stringSMTP_HOST=smtp.example.comLOG_LEVEL=info

Both files hold secrets, so both want chmod 600 and both belong in .gitignore, with a committed app.env.example listing the keys and no values. Variables set under environment: override anything from env_file:, which is the precedence to remember when a value is mysteriously not what the file says.

Check what Compose actually resolved before blaming the application:

bash
$ docker compose config$ docker compose config --services

docker compose config prints the fully merged and interpolated file. An empty value where you expected a password means the .env file is not where Compose is looking, which is nearly always because you ran the command from the wrong directory. Environment variables and secrets covers the wider question of what should be an environment variable in the first place.

For anything you would rather not have in a process environment, Compose supports file-based secrets: a secrets: block names a host file, and the service gets it mounted read-only at /run/secrets/<name>. Many images take a ..._FILE variant of their password variable specifically so you can point it there.

Volumes: what must survive a down#

Every service in the example above is disposable except for what is in the named volumes. That is deliberate, and it is the mental model to keep: containers are cattle, volumes are the herd.

PathKindWhy
dbdata:/var/lib/postgresql/datanamed volumeThe database. Losing it loses everything
caddy_data:/datanamed volumeIssued certificates and ACME account keys
./Caddyfile:/etc/caddy/Caddyfile:robind mountYou edit it, so keep it in the repo

caddy_data is easy to forget and annoying to lose: without it, every recreate of the proxy re-requests certificates from scratch, and certificate authorities rate-limit issuance per domain per week. A few careless rebuilds and you are locked out of new certificates for days on a domain that is serving real traffic.

Backups are a database dump on a schedule, not a copy of the data directory while it is being written to:

bash
$ docker compose exec -T db pg_dump -U notes -Fc notes > /srv/backups/notes-$(date +%F).dump$ docker compose exec -T db psql -U notes -d notes < /srv/backups/restore-test.sql

The -T matters: without it, exec allocates a pseudo-terminal and corrupts a redirected binary dump with line-ending translation. Put the first line in a cron job or a systemd timer, copy the results off the machine, and then restore one somewhere harmless to prove the file is real. A backup nobody has restored is a hypothesis - database backups and restores is the longer argument.

depends_on, healthchecks and start order#

depends_on in its short form controls start order only. It waits for the container to be created and started, not for the software inside it to be ready to answer. A PostgreSQL container takes several seconds to initialise on first run, so an application that connects at boot will fail against a database that is technically running.

The long form fixes it by waiting on a healthcheck:

yaml
    depends_on:      db:        condition: service_healthy

Which requires the database to define one, as the example does with pg_isready. The four knobs are worth understanding: interval is how often the check runs, timeout is how long one attempt may take, retries is how many consecutive failures make it unhealthy, and start_period is a grace window at the beginning during which failures do not count. A database restoring a large dump on first start needs a generous start_period or it will be declared unhealthy while it is doing exactly what it should.

The three conditions available are service_started (the default short-form behaviour), service_healthy, and service_completed_successfully, the last of which is how you run a migration job before the application starts.

Even with all this, write the application to retry its database connection with a short backoff. Start ordering solves the first thirty seconds; a database restart at three in the morning is the other case, and a service that exits permanently because a connection failed once is a service that needs a human at three in the morning. Migrations without downtime covers the schema side of the same problem.

The commands you will actually type#

bash
$ docker compose up -d              # create or update everything, detached$ docker compose ps                 # what is running, and its health$ docker compose logs -f --tail 100 app$ docker compose exec app sh        # a shell in the running container$ docker compose run --rm app npm run migrate$ docker compose restart app        # restart without recreating$ docker compose stop               # stop, keep everything$ docker compose down               # remove containers and network

up -d is idempotent and is the command you use for almost everything. It compares the file with reality and recreates only the services whose definition changed, which means editing one service's image tag and running up -d touches that service alone and leaves the others running.

The difference between exec and run catches people: exec enters a container that is already running, run starts a new one from the same definition. Use run --rm for one-off tasks like a migration or a management command, and exec for looking at something live.

Two more worth knowing. docker compose pull fetches newer images without touching the running stack, so you can pull at a convenient moment and switch at a quieter one. And docker compose --profile debug up -d starts services tagged with a profiles: key, which is how to keep an admin tool or a database GUI in the same file without running it all the time.

Updating, rolling back and the release you regret#

Updating is two commands, and the tag in the file is what makes them safe:

bash
$ docker compose pull app$ docker compose up -d app

Because the images are pinned to 1.4.2 rather than latest, pull only fetches something new when you have edited the file - which is the point. Deploying is then an edit to one line, a commit, and up -d. Rolling back is the same edit in reverse, and it works because you have not deleted the old image yet; docker image ls will show it sitting there. Keep at least the previous tag until the new one has survived a day.

Two things this simple flow does not give you. There is a gap of a few seconds while the old container stops and the new one starts, during which the proxy has nothing to talk to and visitors get a 502. For a hobby stack that is acceptable; if it is not, zero-downtime deploys on a small server covers the patterns that close the gap. And the database is not versioned with the application, so a release that requires a schema change needs the migration to run in a separate step, ideally one that is safe against both the old and the new code.

For the stack to come back after a reboot, restart: unless-stopped on every service is usually enough, since the Docker daemon starts at boot and brings them with it. If you want the stack tied to the machine's own startup ordering, or you want it to wait for a mounted filesystem, wrap docker compose up -d in a small systemd unit instead - systemd services for your apps has the file.

When the stack will not come up#

`service "app" depends on undefined service db`. A typo or an indentation error. YAML is whitespace-significant and two spaces is the convention; a tab anywhere in the file is a syntax error. docker compose config catches both before you start anything.

Port is already allocated. Another process, or another Compose project, holds the host port. sudo ss -lntp names it. Two projects cannot both publish port 443; that is what the proxy is for.

The app cannot resolve `db`. Both services must be in the same project and the same network. If you split the stack across two compose files, they get two networks - declare an external network in both, or keep them in one file.

The database starts fresh every time. The volume is not attached where the image expects it, so the data is going into the container's writable layer and dying with it. docker compose exec db ls -la /var/lib/postgresql/data should show files, and docker volume ls should show a volume with the project prefix.

Changing `POSTGRES_PASSWORD` had no effect. The official database images use those variables only when initialising an empty data directory. On an existing volume the password is whatever it was on day one, and you change it with ALTER USER inside the database.

Everything is healthy and the site returns 502. The proxy is reaching the wrong port or the wrong name. Check that the application actually listens on 0.0.0.0 inside the container rather than 127.0.0.1, because a container's loopback is not shared with the proxy's.

Out of disk. Old images and build cache. docker system df shows where it went; docker image prune -a and docker builder prune reclaim it safely. Avoid the --volumes flag unless you are certain.

A stack like this fits comfortably in a small VDS: the proxy needs almost nothing, PostgreSQL is happy in a gigabyte for a small site as long as you tune it, and the application is whatever it is. PostgreSQL tuning for small servers is the settings side of that. What a VDS gives you that a managed plan cannot is exactly this: a kernel, a Docker daemon and root, so the stack in your file is the stack that runs.

FAQ#

Is Compose overkill for one container?

Slightly, but it still pays for itself. A one-service file records the flags you would otherwise retype, makes updates two commands, and costs nothing at runtime. The moment you add a database it becomes the obvious way to do it.

Should I use build: or a prebuilt image?

Build elsewhere and pull a tagged image if you can. Building on the server ties deployments to the box's CPU and disk, fills the build cache, and means a failed build leaves you with nothing running. Building in place is acceptable for a small project as long as you keep the previous image.

Does docker compose down delete my database?

Not on its own. It removes containers and networks and keeps named volumes. docker compose down -v deletes the volumes, and that one does destroy the database with no prompt.

How do I run several stacks on one server?

One directory each, one compose.yaml each, and only one of them publishing ports 80 and 443. Put the shared proxy in its own project on an external network that the others join, or give each application a distinct internal port and route by hostname in the single proxy.

Can I use Compose with a managed hosting plan?

No. Compose drives a Docker daemon, and a managed game or app plan is itself a container without one. That is one of the clearer reasons to move to a VDS, where the daemon and the root account are yours.

What replaces Compose when this stack grows?

Usually nothing, for a long time. One server with a handful of services is exactly the size Compose fits. When you genuinely need more than one machine, the next step is a scheduler, and the step after that is a team that maintains it - both are large costs that are easy to take on too early.


კომენტარები

სრულიად ანონიმურად: ანგარიშის, ელფოსტის და cookie-ის გარეშე. ინახება მხოლოდ სახელი, ტექსტი და დრო - სხვა არაფერი. ბმულების რაოდენობა ლიმიტირებულია.

0/2000