Hey folks, if you’re running an Express app as a vendor—like I am, we build and deploy Express-powered tools for clients every day—you know how much of a pain inconsistent environments are. One day it works on your dev machine, the next it’s throwing dependency errors in QA, and production… well, production’s a whole other mess. That’s where Docker comes in. It’s not some fancy tech toy; it’s the fix for “it works on my machine” that we’ve relied on for years. Today I’m breaking down exactly how to build a Docker image for an Express app, from the basics to the little tricks that save us hours of debugging—no overly complicated jargon, just what actually works for us as an Express app provider. Express

First, let’s get this straight: I’m not here to teach you Docker 101. I’m assuming you’ve got a working Express app already, right? The kind you’ve tested locally, it listens on a port, maybe uses some npm packages, maybe a tiny bit of middleware, and you’re ready to make it portable. We’ve built dozens of these, so I know the mistakes people make here—like using the wrong base image, or installing unnecessary packages that bloat the image. Let’s skip those.
Step 1: Set Up the Files You Actually Need (No Junk Allowed)
First, go to the root of your Express project. You’ll need two key files, and if you’re skipping any, stop here: a package.json and package-lock.json (or yarn.lock if you use Yarn, but we’re sticking to npm since most devs know it). These are non-negotiable because they tell Docker exactly what dependencies to install.
Wait, and one more—your Dockerfile. That’s the blueprint for your image. No, you don’t need a docker-compose.yml for a simple Express app (we only use that for multi-service setups, like if we pair our Express API with a Redis cache or MongoDB, but that’s beyond this guide). Also, delete any node_modules folder right now. Do it. Docker doesn’t need local node_modules because we’ll install them inside the container, and having local ones can cause version mismatches. I’ve seen new devs waste 2 hours because they forgot that. Trust me.
Step 2: Pick the Right Base Image (Size Matters)
Okay, next up: choosing a Node.js base image. This is where a lot of people go wrong. You don’t need the biggest, beefiest Node image out of the box. For Express apps, we use slim Alpine images almost every time. Why? Because they’re tiny—way smaller than the full Node image—so they pull faster, deploy faster, and have less attack surface (which is a big deal when we’re shipping our app to clients who care about security).
Let’s make this concrete. When you pull a Node image, you’ll see tags like node:20-alpine, node:20-slim, node:20. We use node:20-alpine right now because it’s the sweet spot. It’s based on Alpine Linux, which is super lightweight, and it has everything Node needs—no extra fluff. If you need something with a few more system libraries (maybe if you’re using a native Node module that needs some OS-level packages), you could use node:20-slim, but stick to Alpine first. That’s what we use for 90% of our Express apps.
Pro tip: Always pin the Node version, not just use node:alpine. Like, use node:20.10.0-alpine instead of node:alpine. If you don’t, when Node 21 drops next month, your Docker build will pull the new version, and if there’s a breaking change, your app will crash. We learned that the hard way with a client last year—their Express app broke in prod because Node 21 changed a middleware method. Pinning versions avoids that. Easy win.
Step 3: Write the Dockerfile (Line by Line, No Copy-Paste Fluff)
Now let’s build the Dockerfile line by line. I’ll show you the exact one we use for every basic Express app, then explain each part so you know why it works, not just what to type.
Here’s the baseline Dockerfile:
# Use a pinned, lightweight Node base image
FROM node:20.10.0-alpine
# Set a working directory inside the container (no more messy paths!)
WORKDIR /app
# Copy ONLY package files first—this is a key caching trick
COPY package*.json ./
# Install dependencies—clean install for consistency
RUN npm ci --only=production
# Copy the rest of your app code (after dependencies, to leverage caching)
COPY . .
# Tell Docker what port your app listens on
EXPOSE 3000
# Command to start your Express app
CMD ["node", "app.js"]
Wait, let’s break that down because that npm ci line is important. I see so many people use npm install here, and that’s fine for local dev, but for Docker builds, npm ci is way better. Why? npm ci uses the exact versions listed in package-lock.json, so you get the same dependency tree every time. No “this package updated last night” surprises. We’ve had clients have production outages because npm install pulled a minor version that had a bug—never again since we switched to npm ci.
Also, notice we copy the package files before the rest of the app? That’s the caching trick. Docker caches each layer of the image. If you change your app code but don’t touch package.json, Docker will skip the dependency install layer entirely, which cuts build time from 10 seconds to 1 second. Game changer when you’re building images 10 times a day for testing.
Another thing: --only=production in npm ci. If you have dev dependencies (like testing frameworks, linters, etc.), you don’t need them in the production image. They just add bloat. We had one client’s Docker image go from 300MB to 80MB after adding that flag—insane difference. Just make sure your app doesn’t use any dev dependencies at runtime, which it shouldn’t anyway.
Quick note on the EXPOSE line: That’s just documentation. It tells anyone looking at the Dockerfile that the app uses port 3000. You still have to map the port when you run the container (more on that in a sec). The CMD line is the actual command that starts your app—adjust that if your entry point is different, like server.js instead of app.js.
Step 4: Build the Image and Test It (Don’t Skip Testing!)
Okay, now we’re ready to build the image. Open your terminal, navigate to your project root, and run:
docker build -t my-express-app .
Replace my-express-app with a name that makes sense—something your team recognizes, and that your client will know if you’re sharing the image. The -t flag tags the image, so you don’t have to reference its random ID later.
This is where we test it before shipping it to clients, because you don’t want to send a broken image. Run the container with:
docker run -p 3000:3000 my-express-app
Wait, the -p 3000:3000 part is port mapping. That’s why EXPOSE wasn’t enough—you have to bind your local machine’s port (the first number) to the container’s port (the second, which is what your app is listening on). If your app uses port 4000, change that to -p 4000:4000.
Now go to your browser or use curl to hit http://localhost:3000 (adjust the port if needed). If your Express app loads, you’re golden. If not, let’s troubleshoot the common issues we run into:
- Check if the entry point in
CMDis correct—if you get a “cannot find module app.js” error, that’s probably it. - Did you forget to copy the app code? Make sure the
COPY . .line is after the dependency install. - Are you using a native module? If so, you might need extra system packages (like
python3ormake) in the image. For that, add aRUN apk add --no-cache python3 make g++line right after theFROMline, since Alpine usesapkfor package management. We only do that if we need it—native modules are rare for Express, but good to know.
Step 5: Extra Tweaks for Production (We Use These for Client Deployments)
Now, this is the part that turns a test image into something you can ship to a client. We’ve shared basic images with clients, but adding these small tweaks makes them production-ready and way more reliable:
- Non-root user: By default, Docker runs containers as root, which is a security risk. You don’t want a breach to give root access to your host machine. Add these lines right after
WORKDIR /app:# Create a non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup # Change ownership of the working directory to the non-root user RUN chown appuser:appgroup /app # Switch to the non-root user USER appuserThat’s a quick security win, and we always add this for client-facing images.
- Environment variables: If your Express app uses environment variables (like API keys, database URLs), don’t hardcode them. You can pass them when you run the container with
-eflags, or use a.envfile for local testing. For production, clients usually use Docker secrets or their own environment management, but we include a note in our Dockerfile that they can pass vars easily. - Healthchecks: This is super useful for deployment tools like Kubernetes. Add a line to check if your app is responding:
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost:3000/health || exit 1Just make sure you have a
/healthendpoint in your Express app that returns a 200 OK if everything’s working. Clients love this because it lets their deployment tool know if the container is actually healthy, not just running.
Common Mistakes We See (And How to Avoid Them)
Let’s cut to the chase—these are the mistakes that make new vendors mess up their Express Docker images, and we’ve made every single one:
- Using the wrong Node image size: Don’t use
node:latestfor production. It’s huge, and you never know when it’ll update to a breaking version. Stick to pinned slim or Alpine images. - Copying too much too soon: If you copy
node_modulesor.envbefore dependencies, Docker will re-install dependencies every time you build, slowing things down. The package files first trick is non-negotiable. - Using
npm installinstead ofnpm ci: As I mentioned earlier, that leads to inconsistent dependency versions.npm ciis made exactly for Docker builds, use it. - Not testing locally before pushing: We always test the image locally with
docker runbefore sending it to a client or a registry. Nothing’s worse than pushing an image that crashes on the first request.
By the way, we’ve used this exact process for so many clients—small startups needing a quick API deployment, mid-sized companies porting their internal tools, even enterprise clients needing compliant, lightweight images. The best part is that once you have this Dockerfile set up, you can reuse it for every Express app you build. No starting from scratch every time.

If you’re a fellow Express app vendor looking to standardize your deployment workflow, or if you have a custom Express project that needs a reliable Docker setup, reach out to us to discuss your needs. We tailor Docker configurations to fit your specific use case, whether you need a tiny, fast image for edge deployments or a more robust setup for complex applications.
References
Air Freight Air Express Docker Official Documentation, Node.js Official Docker Images, npm CI Documentation, Express.js Official Deployment Guide
Foshan Shijie International Logistics Co., Ltd.
With abundant experience, we are one of the most professional express service suppliers in China. We are committed to offering reliable express service and logistics solutions with low price. If you have any enquiry about quote, please feel free to email us.
Address: Room 701A, Office Building, Jinbo Commercial Center, No. 88, Guiye Road, Guicheng Street, Nanhai District, Foshan City, Guangdong Province
E-mail: alicesmith.tradingbusiness@gmail.com
WebSite: https://www.fsshijielogistics.com/