How To Set Up a Local Development Environment for a Full-Stack App with Docker Compose
Tricknowtech Team 18 min read
Goal
By the end of this tutorial you will have a docker-compose.yml that runs a Node.js application alongside Postgres and Redis, reloads the app automatically as you edit code on the host, waits for the database to report healthy before starting the app, and keeps secrets out of version control via a .env file.
Prerequisites
An Ubuntu 22.04 server or local machine with a non-root user that has sudo privileges.
Docker Engine and the Docker Compose plugin installed — verify with `docker compose version`. A reasonably recent release is required for the `docker compose watch` command used in Step 7.
Node.js and npm installed locally, used only to scaffold the application's package.json — the application itself runs inside a container.
Git installed, for the .gitignore step.
Comfort with a terminal text editor (nano, vim, or similar) for editing config files.
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
A full-stack application under active development usually depends on more than just its own code — a relational database, a cache, sometimes a message broker — and getting all of those running consistently on every contributor's machine is a recurring source of friction. Docker Compose solves this by letting you describe the whole stack as a single declarative file and bring it up with one command, so "clone the repo and run one command" replaces a page of manual setup instructions.
This tutorial builds a docker-compose.yml for a typical three-service stack: a Node.js application built from a local Dockerfile, a Postgres database, and a Redis cache. Along the way you will configure bind mounts so edits on your host appear inside the running container immediately, move secrets into a .env file that stays out of version control, add a healthcheck so the application waits for Postgres to actually be ready instead of just started, and use docker compose watch so adding a dependency does not require you to remember to rebuild the image by hand.
Prerequisites
An Ubuntu 22.04 server or local machine with a non-root user that has sudo privileges.
Docker Engine and the Docker Compose plugin installed — verify with `docker compose version`. A reasonably recent release is required for the `docker compose watch` command used in Step 7; if the subcommand is not recognized, update Docker.
Node.js and npm installed locally. These are used only to scaffold the application's package.json — the application itself runs inside a container.
Git installed, for the .gitignore step.
Comfort with a terminal text editor (nano, vim, or similar) for editing config files.
Step 1 — Creating the Project Directory and a Minimal Application
Start by creating a project directory that will hold docker-compose.yml, with an app/ subdirectory for the application source. Scaffold a minimal Express application inside app/ with npm, along with the pg and redis client libraries you will use to prove the database and cache connections work, plus nodemon as a dev-only dependency that restarts the process on file changes.
Tricknowtech VPS Hosting
Dedicated KVM resources and full root access — deployed in under 60 seconds, no ticket required.
Create app/server.js with a small HTTP server exposing a /health endpoint that touches both the database and the cache — useful later for confirming the whole stack is actually wired together, not just that the app container started.
Step 2 — Writing a Development Dockerfile for the App Service
The app service needs its own image, built from a Dockerfile you control. For local development you do not need a multi-stage, production-hardened build — just a base Node.js image, the project's dependencies installed, and a command that runs nodemon so the process restarts itself on file changes. Save this as app/Dockerfile.dev, keeping it clearly separate from any production Dockerfile you might add later.
dockerfile
FROM node:20-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
Add a .dockerignore file next to it so node_modules and other host-only artifacts never enter the build context — the container installs its own node_modules during the build instead.
text
node_modules
npm-debug.log
.git
.env
Step 3 — Defining the Database and Cache Services
Back in the project root (~/fullstack-app), create docker-compose.yml defining three services: app (built from the Dockerfile you just wrote), db (an official Postgres image), and cache (an official Redis image). Compose creates a private network for the project and registers each service name as a DNS hostname on it, so the app service reaches Postgres at db:5432 and Redis at cache:6379 — never localhost or 127.0.0.1 from inside a container. Binding db and cache's published ports to 127.0.0.1 only, rather than all interfaces, lets you connect from host tools like psql without exposing the development database beyond your own machine.
Hardcoding database credentials directly into docker-compose.yml would put them in version control. Instead, place them in a .env file in the project root: Compose loads this automatically both for variable interpolation inside docker-compose.yml itself and, via each service's env_file: entry, to populate that container's actual environment.
Commit a sanitized copy instead of the real file: copy .env to .env.example and replace the real password with a placeholder, then add .env and app/node_modules to .gitignore so neither is ever staged.
bash
cp .env .env.example
# edit .env.example and replace devpassword with a placeholder like changeme
echo ".env" >> .gitignore
echo "app/node_modules" >> .gitignore
“If real credentials ever get committed and pushed, treat them as compromised and rotate them, even after removing the file in a later commit — the values remain visible in git history.”
Step 5 — Adding a Database Healthcheck and Gating the App with depends_on
A plain depends_on only waits for the db container's process to start, not for Postgres to finish initializing and accept connections. On first boot, with an empty data directory, the app container frequently starts faster than Postgres and fails its first connection attempt. Add a healthcheck to the db service using Postgres's own pg_isready utility, then change app's depends_on from the short list form to the long form with condition: service_healthy so Compose starts app only once db reports healthy; cache keeps condition: service_started since Redis is ready almost immediately.
The doubled $$ before each variable escapes Compose's own file-level interpolation so a literal $POSTGRES_USER reaches the shell inside the db container, which then expands it from the environment the official Postgres image sets from those same values. start_period gives Postgres a grace window before failed checks start counting toward retries, which avoids a false-unhealthy state during first-time database initialization.
Step 6 — Enabling Live Code Reload with Bind Mounts
As written so far, editing server.js on your host has no effect on the running container — the code was copied in once, at build time. Add a bind mount that maps the host's app/ directory onto the container's /usr/src/app, plus an anonymous volume over node_modules specifically, so the host directory (which may lack node_modules, or hold modules built for a different OS) does not shadow the modules already installed inside the image.
With this in place, nodemon — already the container's dev command — notices the change land on disk and restarts the Node process in place. You edit server.js in your normal host editor and the running container picks it up within a second or two, with no docker compose build required for ordinary code changes.
Step 7 — Iterating on Dependencies with docker compose watch
The bind mount covers application code, but it does not help when you add a new npm dependency: package.json changes on the host, yet the image's installed node_modules is unaffected until you rebuild. docker compose watch closes that gap by combining a sync action, which pushes changed files into the running container without a rebuild, with a rebuild action that tears down and rebuilds a service when files matching a given path change.
Run docker compose watch instead of docker compose up -d during a work session. Ordinary file edits under app/ sync into the container over the existing connection exactly like the bind mount, while editing app/package.json triggers an automatic rebuild and restart of just the app service — you no longer have to remember to pass --build yourself after adding a dependency.
“docker compose watch requires a fairly recent Compose plugin release. Run `docker compose version` first; if the watch subcommand is missing or errors out, update Docker Engine / Docker Desktop and try again.”
Step 8 — Starting the Stack and Verifying Everything Works
With all three services and every option above in place, the complete docker-compose.yml looks like this:
A successful response looks like {"status":"ok"}. To confirm live reload, edit the message in server.js's /health handler, save, watch the app logs show nodemon restarting the process, then curl the endpoint again and see the updated response — with no manual rebuild step in between.
Step 9 — Useful Commands for Everyday Development
A handful of Compose commands cover most of a normal development session: streaming logs from one service, opening a shell or database client inside a running container, restarting a single service after a config change, and tearing the stack down cleanly at the end of the day.
bash
docker compose logs -f app
docker compose exec app sh
docker compose exec db psql -U appuser -d appdb
docker compose exec cache redis-cli
docker compose restart app
docker compose down
docker compose down -v
docker compose build --no-cache app
“docker compose down -v removes named volumes along with the containers — that deletes db_data and cache_data, wiping your development database and cache. Use it only when you deliberately want a clean slate; plain docker compose down leaves the volumes, and therefore your data, intact.”
Conclusion
You now have a docker-compose.yml that runs a Node.js application alongside Postgres and Redis as a single reproducible stack. Configuration and secrets live in a .env file kept out of version control via .gitignore, the app container waits for Postgres to be genuinely ready through a healthcheck and depends_on's condition: service_healthy rather than racing it, bind mounts give you instant reload on ordinary code edits, and docker compose watch handles rebuilds automatically when dependencies change. From here, the same pattern extends naturally to additional services — a separate frontend container, a message queue, a second database — and to a docker-compose.override.yml or a distinct production Dockerfile once you are ready to move this setup beyond your own machine.