on this page

Your container builds. It starts. The logs even say Server listening on port 3000, cheerful as anything. You open http://localhost:3000 and get nothing. A spinner, then a browser error, then that specific dread where you start wondering if you typed the URL wrong four times in a row.

Welcome to the trio. Ports, volumes, and environment variables are the three settings that eat most first Docker setups, and they all fail in the same confusing way: the container looks fine from the inside and is completely useless from the outside. If you’ve already read Docker without the buzzwords, you know what a container is and roughly what these flags are for. This guide is about why they keep betraying you, and how to read them so they stop.

We’ll go one at a time, because all three of these are easy once the mental model clicks and miserable until it does.

Ports: your container is on its own private network

Here’s the thing nobody says out loud when you start: a container has its own network. Not a metaphor for one. An actual private network, separate from your laptop, with its own idea of what localhost means.

So when your Node app inside the container binds to port 3000, it opens that port on the container’s network. Not on your machine. From your laptop’s point of view, that port may as well be on a server in another building. The app is genuinely running and genuinely listening. You just have no road to it yet.

The road is the -p flag. Publishing a port pokes a hole from your laptop into the container so traffic can actually get through.

# Run a container and publish port 3000
docker run -p 3000:3000 my-app

That 3000:3000 is the part everyone reads wrong, so let’s be exact about it. The format is HOST:CONTAINER. The left number is the port on your laptop. The right number is the port inside the container. Docker connects them: traffic arriving at the left port gets forwarded to the right port.

They do not have to match. This is the bit that unlocks everything:

# Laptop port 8080 maps to container port 3000
docker run -p 8080:3000 my-app

# Now you visit http://localhost:8080
# and it hits the app listening on 3000 inside the container

Read that left-to-right like a sentence: “take port 8080 on my machine and send it to port 3000 in the container.” The app inside never knew it moved. It’s still on 3000. You’re just knocking on a different door.

CommandHost side (your laptop)Container side (the app)You open in browser
-p 3000:300030003000localhost:3000
-p 8080:300080803000localhost:8080
-p 5000:300050003000localhost:5000
-p 3000:80300080localhost:3000

That last row is the common one for a web server like nginx, which listens on port 80 inside the container. You publish your laptop’s 3000 to the container’s 80, and you browse to localhost:3000 like normal.

The classic bug has two flavors, and they feel identical from the browser. Flavor one: the app is happily listening inside the container, but you never passed -p at all, so there’s no road in and the request just dies. Flavor two: you published the wrong side. You ran -p 3000:8080 expecting your app on 3000, but the app actually listens on 3000 inside, so Docker is forwarding your traffic to port 8080 in the container where nothing is home.

One more gotcha that costs people an afternoon: inside the container, your app has to listen on 0.0.0.0, not 127.0.0.1. If a Node or Express app binds to 127.0.0.1, it’s only accepting connections from inside the container itself, so Docker’s forwarded traffic gets refused at the door. Binding to 0.0.0.0 means “accept connections on any interface,” which is what lets the published port actually reach it. Most frameworks do this for you. When one doesn’t, this is the bug.

Volumes: the container’s filesystem is disposable on purpose

Containers are built to be thrown away. That’s a feature, not a bug, and it’s also why your database vanishes.

When you remove a container, its filesystem goes with it. Everything written inside, every uploaded file, every row in that Postgres database you spun up, gone, as if the container never existed. For your app’s code this is fine. For data you actually wanted to keep, it’s a disaster you only discover after running docker compose down and watching your local database reset to empty.

A volume is how data survives. It connects storage that lives outside the container’s disposable filesystem, so when the container disappears, the data stays put. There are two kinds, and the difference matters more than the syntax does.

A named volume is storage Docker creates and manages somewhere on your machine. You give it a name, Docker handles where it physically lives, and it persists until you explicitly delete it. This is what you want for databases and anything stateful.

# Postgres stores its data in /var/lib/postgresql/data inside the container.
# Point a named volume there so the data outlives the container.
docker run -v pgdata:/var/lib/postgresql/data postgres:16-alpine

Now pgdata holds your database. Restart the container, recreate it, upgrade the image: the data is still there because it was never inside the container in the first place.

A bind mount is different. It maps a folder from your laptop straight into the container, so both sides see the same files in real time. Edit a file on your machine and the container sees the change instantly. This is what makes live-editing your source code in dev actually work.

# Mount your current project folder into /app inside the container.
# Now edits on your laptop show up in the container immediately,
# so your dev server can hot-reload them.
docker run -v "$(pwd):/app" my-app

The mental split is simple once you have it. Named volumes are for data Docker should babysit: your database, a cache that should survive restarts. Bind mounts are for your source code in development, where you want your editor and the container looking at the exact same files. You generally do not use a bind mount for a production database, and you generally do not need a named volume just to edit your code.

This guide keeps it deliberately shallow, because volumes get deep fast (read-only mounts, mount propagation, permissions across operating systems, the lot). When you hit a wall, the Docker volumes guide goes properly into it. For now, the two-sentence version above covers most of what trips beginners.

Environment variables: how config gets in

A container ships as a sealed unit. So how does it learn your database password, your API keys, or which port to run on? You hand that config in from the outside as environment variables, which are just KEY=value pairs the running process can read.

The simplest way is one flag per value:

# Pass two environment variables into the container
docker run -e PORT=3000 -e NODE_ENV=production my-app

Inside the container your app reads these the normal way, process.env.PORT and process.env.NODE_ENV in Node. Nothing special, this is the same mechanism your app already uses locally. Docker is just the thing setting them.

Once you have more than two or three, listing them on the command line gets old. Put them in a file instead:

# .env  (do not commit this file)
PORT=3000
DATABASE_URL=postgres://postgres:password@db:5432/myapp
REDIS_URL=redis://cache:6379
JWT_SECRET=change-me-in-real-life
# Load every variable from the file at once
docker run --env-file .env my-app

In Docker Compose you don’t pass flags at all. You declare the config in the file, either inline under environment or pulled from a file with env_file:

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - PORT=3000
      - NODE_ENV=production
    env_file:
      - .env

The environment key sets values right there in the Compose file. The env_file key loads them from a separate file. Use env_file for the long list of real config and keep secrets out of the committed YAML. This is how a container actually gets its database URL, its API keys, and its port. None of it is baked into the image. It all gets passed in fresh every time the container runs. That separation is the whole point. The same image can run against your local database today and a production one tomorrow, with nothing changing but the variables.

Why localhost inside a container almost never means your laptop

This one deserves its own section because it breaks more multi-container setups than the other two combined, and it ties the whole trio together.

Remember the first rule: every container has its own private network. That means every container has its own localhost, and inside a container, localhost points at that container, not your laptop and not any other container.

So picture the standard setup. Your Express backend in one container, Postgres in another. You copy your local config straight in and your backend tries to reach the database at localhost:5432. It fails instantly, connection refused, and the error makes no sense because it works fine on your machine. The reason: from inside the backend container, localhost:5432 means “port 5432 on this backend container,” where there is no database. The database is a different container entirely, sitting on the same Docker network under a different name.

The fix is to stop using localhost and start using the other container’s name. In Docker Compose, every service is reachable by its service name, so the database isn’t at localhost, it’s at db:

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      # Reach Postgres by its SERVICE NAME (db), never localhost
      - DATABASE_URL=postgres://postgres:password@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data

  cache:
    image: redis:7-alpine

volumes:
  pgdata:

The backend reaches Postgres at db:5432 and Redis at cache:6379, using the service names as hostnames. Compose wires up a shared network and a little internal DNS so those names resolve to the right containers automatically. There’s real depth here (custom networks, aliases, talking to services across separate Compose files), and the Docker networking guide covers it. The one rule that saves you most often: between containers, address each other by name, and save localhost for talking to a service inside the very same container.

Notice how all three settings showed up in that one file. The database URL the backend needs is an environment variable. The host it points at is a networking question. And pgdata is the volume keeping your data alive across restarts. They’re not separate topics so much as three faces of the same setup.

Common mistakes, ranked by how often they ruin an evening

You will hit at least three of these. Everyone does.

SymptomWhat’s actually wrongThe fix
Browser can’t connect at allForgot -p, so no port is publishedAdd -p HOST:CONTAINER to docker run
Connection refused on the published portApp binds to 127.0.0.1 inside the containerBind to 0.0.0.0 so forwarded traffic reaches it
Right port, still nothingWrong side of -p, container number doesn’t match the appMake the right-hand number match the app’s real port
Database empties on every restartNo volume, so data lives in the disposable containerMount a named volume at the data directory
Backend can’t reach the databaseUsing localhost to reach another containerUse the service name as the host (db, cache)
Code edits don’t show up in devNo bind mount, so the container has a frozen copyBind-mount your source folder into the container
App reads undefined for its configEnv variables never passed inUse -e, --env-file, or Compose environment / env_file
Secret leaked to GitHubCommitted .envGitignore .env, rotate the secret, commit .env.example

Keep this table somewhere. When a container is misbehaving, ninety percent of the time the cause is one row in it, and the trick is just figuring out which.

Tying it back to shipping something real

These three settings feel fussy in isolation, like Docker invented busywork. They aren’t. They’re the exact same questions every real deployment asks, just asked early on your laptop where the stakes are zero.

When you push a service to Railway

Heads up. You're leaving raindev.fyi

This link heads to railway.app, an external site we don't control. Cool to keep going?

Continue
or Render

Heads up. You're leaving raindev.fyi

This link heads to render.com, an external site we don't control. Cool to keep going?

Continue
, the platform asks which port your app listens on (that’s the right side of -p), where your persistent data should live (that’s a volume), and what your environment variables are (that’s a dashboard of the same KEY=value pairs). A managed Postgres gives you a connection string, and your app reaches it by hostname, not localhost, for the identical reason your two containers couldn’t see each other. The deploy front-end on Cloudflare Pages

Heads up. You're leaving raindev.fyi

This link heads to pages.cloudflare.com, an external site we don't control. Cool to keep going?

Continue
pulling from GitHub

Heads up. You're leaving raindev.fyi

This link heads to github.com, an external site we don't control. Cool to keep going?

Continue
works the same way underneath.

So the afternoon you lose fighting -p 3000:3000 on your own machine is not wasted. It’s the deployment lesson with a fast feedback loop and nothing on fire. Get the trio working locally with Compose, and the production version is mostly the same answers typed into someone else’s web form. When the deploy logs say the app is listening and the site still won’t load, you’ll already know exactly which of the three to check first.