Skip to content

Env Vars, Volumes & Networks

There are two ways to pass environment variables into a service.

Define variables directly in compose.yaml:

services:
app:
image: myapp:latest
environment:
- NODE_ENV=production
- PORT=3000

Reference an external file. Compose reads each KEY=VALUE line from the file and injects it into the container:

services:
app:
image: myapp:latest
env_file:
- .env

The .env file:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://db:5432/mydb

Keep .env out of version control — it usually contains secrets.

Compose reads a .env file in the same directory as compose.yaml and uses it to substitute ${VAR} placeholders inside the compose.yaml itself:

services:
db:
image: postgres:18-alpine
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}

With a .env file containing:

DB_PASSWORD=supersecret
DB_NAME=mydb

Compose substitutes the values before starting the container. This keeps secrets out of compose.yaml and lets different developers use their own local .env files.

Containers are ephemeral — data written inside a container disappears when the container is removed. Named volumes persist data on the host and survive container restarts and recreations.

Declare a named volume at the top level and mount it per-service:

services:
db:
image: postgres:18-alpine
volumes:
- pgdata:/var/lib/postgresql
environment:
- POSTGRES_PASSWORD=secret
volumes:
pgdata:

The top-level volumes: key registers the volume with Compose. The per-service volumes: entry mounts it. You can mount the same named volume in multiple services.

Compose creates a default network automatically. For more control — isolating services or connecting services across Compose files — define custom networks:

services:
frontend:
image: nginx:alpine
networks:
- public
backend:
image: myapp:latest
networks:
- public
- private
db:
image: postgres:18-alpine
networks:
- private
networks:
public:
private:

Here frontend can reach backend, but frontend cannot reach db directly. backend bridges both networks.

The snippet below wires together a service with an env file and a named volume so the data survives restarts.

# Create .env file
cat > .env <<'EOF'
APP_MSG=Hello from .env interpolation!
VOLUME_DATA_DIR=/data
EOF

# Create compose.yaml with env interpolation and a named volume
cat > compose.yaml <<'EOF'
services:
  writer:
    image: alpine
    env_file:
      - .env
    volumes:
      - mydata:${VOLUME_DATA_DIR}
    command: >
      sh -c "
        echo ${APP_MSG} &&
        echo 'Writing to named volume...' &&
        echo 'persistent data' > ${VOLUME_DATA_DIR}/file.txt &&
        echo 'Wrote file to volume.'
      "

  reader:
    image: alpine
    volumes:
      - mydata:/data
    command: >
      sh -c "
        sleep 2 &&
        echo 'Reading from volume:' &&
        cat /data/file.txt
      "
    depends_on:
      - writer

volumes:
  mydata:
EOF

# Run both services
docker compose up

# Verify the volume still exists after containers stop
docker volume ls | grep mydata

# Clean up (keeps the volume)
docker compose down

# Clean up including the volume
docker compose down -v
OptionBenefitCost
.env + interpolationNo secrets hardcoded in compose.yaml, each developer can use their own valuesHave to manage the .env file yourself outside git and keep its keys in sync with what compose.yaml references
Named volume + custom networkData survives restarts, and traffic is clearly separated by networkVolumes/networks are tied to the project name — a name collision can mount the wrong data
  • Accidentally committing a .env file with real secrets (like DB_PASSWORD) to git — always put .env in .gitignore and commit only .env.example.
  • Forgetting that the named volumes and networks Compose creates are project-scoped (named from the directory) — similarly named projects can create duplicate volumes without realizing it.
  • Confusing environment: with env_file: and expecting a .env file to be read into the container automatically without declaring env_file: — interpolation and env_file are two separate mechanisms.

💡 Real-world example

Teams doing CI/CD commonly keep .env.example (committed to git to document which keys need setting) separate from the real .env that holds secrets, injecting the real values through a secrets manager or CI variable instead — the same pattern as using Compose locally and passing real secrets through a Kubernetes Secret when deploying to production.

What is the difference between environment: and env_file: in a service definition?
Where does Compose look for ${VAR} interpolation values?
What happens to a named volume when you run docker compose down (without -v)?
In the networks example, why can frontend NOT reach db directly?