Deploy a Small App with Docker (Step-by-Step)

Deploy a Small App with Docker (Step-by-Step)
Docker lets you package an app (code + runtime + dependencies) into a container so it runs the same way on your laptop and on a server. For small apps, the goal is simple: build one image, run one container, and make upgrades predictable.
This post walks through a clean, repeatable workflow:
- Create a
Dockerfile - Add a
.dockerignore - Build and run locally
- Pass environment variables safely
- Deploy with Docker Compose on a small VPS
- Update the app with minimal downtime
What you need
- Docker installed (
docker version) - A small web app that listens on a port (examples below assume port
3000) - Optional: a Linux VPS (DigitalOcean/Linode/Hetzner/etc.) with SSH access
1) Make sure your app listens on 0.0.0.0
Inside a container, binding to localhost can make the app unreachable from outside the container. In most frameworks you want to listen on 0.0.0.0.
Examples:
- Node/Express:
app.listen(process.env.PORT ?? 3000, '0.0.0.0')
- Next.js:
next start -H 0.0.0.0 -p 3000
- Python/Flask (dev server is not for production, but for local testing):
flask run --host=0.0.0.0 --port=3000
2) Add a .dockerignore
This keeps images smaller and builds faster by excluding files Docker doesn’t need.
Create .dockerignore in your project root (adjust for your stack):
node_modules
.next
dist
build
.git
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
3) Write a minimal Dockerfile
Below is a good default for a small Node app (Express, Fastify, etc.). It uses a multi-stage build so the final image contains only production dependencies.
Create Dockerfile in your project root:
# 1) Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source and build (if your app has a build step)
COPY . .
# If you don't have a build step, you can remove this line.
RUN npm run build --if-present
# 2) Runtime stage
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Copy only what's needed
COPY package*.json ./
RUN npm ci --omit=dev
# If you build to dist/, copy dist; otherwise copy your server entrypoint.
COPY --from=build /app/dist ./dist
# If your app doesn't produce dist/, replace the line above with:
# COPY --from=build /app ./
EXPOSE 3000
# Update this to match how your app starts
CMD ["node", "dist/server.js"]
Notes:
- If your app is Next.js, you’ll likely want
next buildandnext start(or a standalone output). The idea is the same: build in one stage, run in a smaller final stage. - If you’re not on Node, the pattern is still similar: keep the runtime image small, and don’t ship dev tooling to production.
4) Examples by framework (copy/paste)
Use these when you want a “known good” starting point. The main thing to keep consistent is: the port you EXPOSE and the port your app actually listens on.
Next.js (recommended: standalone output)
If you’re deploying a Next.js app, a great pattern is building a standalone server.
- Update
next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;
- Use this
Dockerfile:
# Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Standalone server + static assets
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Run it:
docker build -t my-next-app .
docker run --rm -p 3000:3000 my-next-app
Express (simple production container)
Assuming you start with node server.js and your server binds to 0.0.0.0 and process.env.PORT:
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Run it:
docker build -t my-express-app .
docker run --rm -p 3000:3000 my-express-app
Python (FastAPI + Uvicorn)
Assuming main.py contains a FastAPI app called app:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host=0.0.0.0", "--port=8000"]
Run it:
docker build -t my-fastapi-app .
docker run --rm -p 8000:8000 my-fastapi-app
Go (tiny runtime image)
This builds a static binary and runs it in a minimal runtime image.
# Build
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .
# Runtime
FROM gcr.io/distroless/static-debian12
WORKDIR /
COPY --from=build /src/app /app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Run it:
docker build -t my-go-app .
docker run --rm -p 8080:8080 my-go-app
4) Build and run locally
From the project root:
docker build -t my-small-app:local .
docker run --rm -p 3000:3000 my-small-app:local
Then open http://localhost:3000.
5) Configure environment variables (without baking secrets into the image)
Don’t hardcode secrets in your Dockerfile or commit them to git.
Local run with env vars:
docker run --rm -p 3000:3000 \
-e NODE_ENV=production \
-e DATABASE_URL="..." \
my-small-app:local
For longer lists, use an env file:
docker run --rm -p 3000:3000 --env-file .env.production my-small-app:local
6) Deploy on a small server with Docker Compose
On your VPS, create a folder like /opt/my-small-app and add a docker-compose.yml:
services:
app:
image: my-small-app:latest
container_name: my-small-app
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- .env.production
Then add .env.production (on the server only):
mkdir -p /opt/my-small-app
cd /opt/my-small-app
nano .env.production
Start it:
docker compose up -d
docker compose logs -f
7) Updating the app (repeatable release flow)
Your update process should be boring:
- Build a new image
- Tag it
- Pull it on the server
- Restart via compose
If you’re using a registry (Docker Hub/GHCR):
# locally
docker build -t ghcr.io/YOUR_USER/my-small-app:1.0.0 .
docker push ghcr.io/YOUR_USER/my-small-app:1.0.0
docker tag ghcr.io/YOUR_USER/my-small-app:1.0.0 ghcr.io/YOUR_USER/my-small-app:latest
docker push ghcr.io/YOUR_USER/my-small-app:latest
On the server:
cd /opt/my-small-app
docker compose pull
docker compose up -d
Common pitfalls (and quick fixes)
- App works locally but not in Docker: ensure you listen on
0.0.0.0and expose/map the correct port. - Image is huge: add
.dockerignore, use multi-stage builds, avoid copyingnode_modules. - Secrets leaked: never copy
.envinto the image; keep secrets in server env files or a secret manager. - Container keeps restarting: check logs with
docker compose logs -fand confirm yourCMD/entrypoint path is correct.
Deployment checklist
- App binds to
0.0.0.0 -
.dockerignoreexists -
Dockerfilebuilds withdocker build . - Container runs with
docker run -p host:container - Secrets live outside the image (env vars / env_file)
- Compose uses
restart: unless-stopped - You can update via
compose pull && compose up -d