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:17-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:17-alpine
volumes:
- pgdata:/var/lib/postgresql/data
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:17-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
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?