git pull + docker compose up -d but nothing changed? — 5 traps that cost me 30 minutes each
SSH into VPS, git pull, docker compose up -d... and the new code still isn't live. Here are the 5 culprits I've burned real time on — plus a 60-second checklist to catch which one is biting you.
My ship-to-VPS routine for small freelance sites:
ssh deploy@vps
cd /srv/myapp
git pull
docker compose up -d
Four lines. Thirty seconds when everything works. But there have been times when git pull succeeds, docker compose up -d runs without an error — log says myapp-web is up-to-date — and the browser still shows old code. curl returns the old response. The container is running, but it’s running an image from 3 commits ago.
The first time I hit this I reloaded the browser, flushed CDN cache, even tried a different browser. Then I realized: the problem isn’t on the client. The problem is Docker doesn’t know the code changed yet.
After 4 years of using Docker to deploy small freelance sites, here are 5 traps I keep hitting — each one has cost me a clean 30 minutes before I learned the pattern.

Trap 1: docker compose up -d does not rebuild by default
This is the #1 trap and 90% of Docker newcomers hit it.
When you run docker compose up -d, Compose asks: “is there an image for this service yet?”. If the image already exists (even if it was built from an old commit), it reuses it. It does not check if Dockerfile changed, does not check if source code changed. Image exists — reuse it, done.
It only rebuilds when:
- The image doesn’t exist yet (first run, or you’ve
docker rmi’d it). - You pass the
--buildflag. - You ran
docker compose buildfirst.
Fix: make --build a permanent habit:
docker compose up -d --build
Or split the steps explicitly (cleaner logs):
docker compose build
docker compose up -d
I keep an alias in ~/.bashrc for the deploy user on every VPS:
alias dredeploy='git pull && docker compose build && docker compose up -d && docker compose logs -f --tail=50'
The logs -f --tail=50 at the end saves me from typing a 5th command after the first 4.
Trap 2: Volume mount overwriting your build artifact
Setup: your Dockerfile builds the frontend (npm run build → dist/) and copies dist/ into the image. Nice. But docker-compose.yml has:
services:
web:
build: .
volumes:
- ./:/app
You think “mount source code so dev is convenient” — fine for dev. But ./ on the VPS has no dist/ directory (because git ignores it). When the container starts, the volume mount overwrites /app — wiping out the dist/ you just built into the image. The container runs against an empty folder.
Telltale symptom: docker compose logs shouts ENOENT: dist/index.html, or nginx 404s on every static file.
Fix 1 — split docker-compose.yml (dev with bind mount) from docker-compose.prod.yml (no source mount):
# docker-compose.prod.yml
services:
web:
build: .
# NO volumes: pointing at source code
ports:
- "3000:3000"
Deploy with docker compose -f docker-compose.prod.yml up -d --build.
Fix 2 — use named volumes for things that genuinely need persistence (uploads, db data) instead of binding the whole project:
volumes:
- uploads_data:/app/storage/uploads
# NOT: - ./:/app
I always go with Fix 1. Clean dev/prod separation beats sprinkling if NODE_ENV across the compose file.
Trap 3: .env leaks into your image — or is missing at runtime
This trap is two-sided. You hit one of two flavours depending on how you set things up.
Flavour A — .env missing: you have .dockerignore containing .env* (correct — never bake secrets into images). But you also forgot to declare env_file: in docker-compose.yml. Container runs with process.env.DATABASE_URL = undefined, app crashes on DB connect.
Flavour B — secrets leaked into image: your Dockerfile does COPY . . without a .dockerignore, or .dockerignore doesn’t list .env*. The built image has /app/.env inside — anyone who pulls the image (public registry, a leaked backup) can read your DB password.
Fix — standardise this pattern:
My .dockerignore:
node_modules
.git
.env
.env.*
!.env.example
dist
*.log
docker-compose.yml:
services:
web:
build: .
env_file:
- .env # read from external file, injected at runtime
You drop .env onto the VPS via SCP / Ansible / chmod 600. Image stays clean, container still gets env vars at start. Best of both.
Trap 4: Old images chewing through disk → next pull fails
My VPS has 40 GB. After 6 months of deploying one small site, docker system df:
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 47 2 28.3GB 27.1GB (95%)
Containers 4 2 234MB 185MB (79%)
Local Volumes 8 3 1.2GB 800MB (66%)
Build Cache 312 0 8.4GB 8.4GB (100%)
38 GB of garbage. Each docker compose build creates a new image without removing the old one — rebuild 47 times, you have 47 images. Build cache piles up the same way.
Telltale symptom: git pull shouts error: unable to write file: No space left on device. Or docker compose build errors out mid-process for the same reason.
Periodic fix — run weekly (cron, or manually after a big deploy):
# Remove images with no container referencing them
docker image prune -a -f
# Drop build cache older than 7 days
docker builder prune -a -f --filter "until=168h"
# Remove orphaned volumes (CAREFUL — only when you're sure)
docker volume prune -f
I cron the first line weekly:
0 3 * * 0 docker image prune -a -f >> /var/log/docker-cleanup.log 2>&1
3 AM Sunday — nobody is tunneling in then.
Warning: docker volume prune removes volumes not attached to any container. If your DB lives in a named volume and you ran docker compose down (without -v), the volume stays — prune doesn’t touch it. But if you accidentally docker compose down -v then prune, DB gone. Back up before your first prune.
Trap 5: Build on Mac M1, deploy to x86 VPS — silently broken
Rarer, but brutal to debug because there’s no error at build time. The container just exits immediately on start with exec format error.
Cause: docker build on Apple Silicon defaults to linux/arm64. Common VPS providers (Vultr, DigitalOcean, Hetzner shared) run linux/amd64. An arm64 image won’t run on amd64.
How I hit it: built locally, pushed to Docker Hub, pulled on the VPS, container exits instantly. Log has the cryptic exec /usr/local/bin/node: exec format error.
Fix — always build with explicit --platform if you’re building locally:
docker build --platform linux/amd64 -t myimage .
docker push myimage
Or in docker-compose.yml:
services:
web:
build:
context: .
platforms:
- linux/amd64
Better fix — don’t build locally at all. git pull then docker compose build on the VPS itself. Native CPU, can never be wrong-platform. This is the pattern I keep across every freelance project — only build locally when actively dev-ing.
60-second checklist when a deploy doesn’t go live
I walk this in order, each step < 10 seconds:
# 1. Did the image actually rebuild?
docker compose images # image IDs + tags
# image ID unchanged after a rebuild → trap 1
# 2. Is the container running the image you just built?
docker compose ps
docker inspect <container> | grep Image
# 3. Does the code inside the container match HEAD?
docker compose exec web cat /app/package.json | grep version
# or
docker compose exec web ls -la /app/dist/
# 4. Is a volume overwriting your build output?
docker compose config | grep -A2 volumes
# 5. Disk space?
df -h /
docker system df
Steps 1 + 3 catch 80% of cases. Step 5 catches the next 15%. The remaining 5% is .env missing/wrong (trap 3) — the container log will tell you.
My docker-compose.prod.yml template
After 4 years, this is the skeleton I copy into every new freelance project:
services:
web:
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/amd64
image: myapp:latest
restart: unless-stopped
env_file:
- .env
ports:
- "127.0.0.1:3000:3000" # only via nginx, never raw to internet
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
db:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- .env
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
volumes:
db_data:
Things I always keep:
image: myapp:latest— explicit tag sodocker image prune -adoesn’t nuke the wrong thing.restart: unless-stopped— VPS reboots, container comes back.127.0.0.1:on ports — never expose DB or app raw to the internet, route through local nginx.healthcheck—docker compose psreports unhealthy instead of silently failing.- No
volumes:pointing at source code — solves trap 2.
Closing
Docker is a beautiful tool for environment standardisation — git clone → docker compose up working is what lets me sleep at night. But just like cron, Docker is full of hidden traps: up -d without rebuild, volume overwriting artifacts, old images eating disk, platform mismatches that fail silently.
These five cover ~95% of the “deploy didn’t go live” cases I’ve debugged. The remaining 5% is usually DNS / nginx upstream / network — but that’s another post.
Next time git pull && docker compose up -d doesn’t show the new code, walk the 5 lines docker compose images / ps / exec / config / df above. 60 seconds saves you 30 minutes.