← Blog

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.

Docker logo

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:

  1. The image doesn’t exist yet (first run, or you’ve docker rmi’d it).
  2. You pass the --build flag.
  3. You ran docker compose build first.

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 builddist/) 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 so docker image prune -a doesn’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.
  • healthcheckdocker compose ps reports unhealthy instead of silently failing.
  • No volumes: pointing at source code — solves trap 2.

Closing

Docker is a beautiful tool for environment standardisation — git clonedocker 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.