After getting comfortable with running containers, the next step in my Docker journey was learning how to work with images effectively. This post covers everything I've learned about using existing images, creating custom ones, and managing them properly.
Using Existing Images
Pulling Images from Docker Hub
Docker Hub has thousands of pre-built images you can use. Here's how to get them:
# Pull the latest version of an image
docker pull nginx
# Pull a specific version using tags
docker pull nginx:1.21
# Pull an image from a different registry
docker pull mcr.microsoft.com/dotnet/sdk:6.0
I usually pull images explicitly before using them, but docker run will automatically pull images if they're not available locally.
Understanding Image Tags and Versioning
Tags help you identify different versions of the same image:
[registry/][username/]repository[:tag]
For example:
-
nginx:latest- Official Nginx image, latest version -
username/my-app:1.0- Custom app, version 1.0 -
registry.example.com/team/service:v1.2.3- Private registry image
Important things I've learned about tags:
-
latestdoesn't necessarily mean the newest version - it's just the default tag - Tags are just pointers to image IDs
- Multiple tags can point to the same image
- Tags are mutable - they can be moved to different images
- For production, always use specific version tags, never
latest
Creating Custom Images
Writing Dockerfiles
A Dockerfile is a text file with instructions for building an image:
FROM node:14-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
Common Dockerfile instructions:
-
FROM: Base image -
WORKDIR: Set working directory -
COPY/ADD: Copy files into the image -
RUN: Execute commands during build -
ENV: Set environment variables -
EXPOSE: Document container ports -
CMD/ENTRYPOINT: Default command when container starts
Best Practices for Dockerfile Creation
Through trial and error, I've learned these best practices:
Use specific base image tags
# Good
FROM node:14-alpine
# Not good
FROM node:latest
Order instructions from least to most frequently changing
# Dependencies change less often than source code
COPY package.json .
RUN npm install
COPY . .
Group related RUN commands to reduce layers
RUN apt-get update && \
apt-get install -y something && \
apt-get clean
Use .dockerignore to exclude unnecessary files
node_modules
npm-debug.log
Dockerfile
.git
Run as non-root user for security
RUN useradd -r appuser
USER appuser
Use multi-stage builds (more on this later)
Building Images with docker build
Once you have a Dockerfile, building an image is straightforward:
# Build with a tag
docker build -t my-app:1.0 .
# Build with a specific Dockerfile
docker build -f Dockerfile.prod -t my-app:1.0 .
# Build with build arguments
docker build --build-arg VERSION=1.0 -t my-app:1.0 .
The . at the end specifies the build context - the directory containing your application files.
Understanding Image Layers and Caching
Docker images consist of layers, which are cached for efficiency:
- Each instruction in a Dockerfile creates a layer
- Layers are cached and reused when possible
- If a layer changes, all downstream layers must be rebuilt
- You can see layers with
docker history image-name
This is why the order of instructions matters. For my Node.js apps, I always:
- Copy package.json first
- Install dependencies
- Copy application code
This way, if only my code changes (but not dependencies), Docker reuses the cached dependency layer.
Managing Images
Listing and Removing Images
Basic image management:
# List all images
docker images
# Remove an image
docker rmi image-id
# Remove all dangling images
docker image prune
# Remove all unused images
docker image prune -a
# Force remove all images
docker rmi $(docker images -q) -f
Tagging and Pushing to Registries
To share your images, you need to tag and push them:
# Tag an image
docker tag my-app:1.0 username/my-app:1.0
# Log in to Docker Hub
docker login
# Push to Docker Hub
docker push username/my-app:1.0
# Push to another registry
docker login registry.example.com
docker tag my-app:1.0 registry.example.com/team/my-app:1.0
docker push registry.example.com/team/my-app:1.0
Multi-Stage Builds for Smaller Images
This technique dramatically reduces image size by using multiple FROM statements:
# Stage 1: Build stage
FROM node:14 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Production stage
FROM node:14-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm install --only=production
EXPOSE 3000
CMD ["node", "dist/index.js"]
Benefits I've seen with multi-stage builds:
- Final images are much smaller
- Build tools aren't included in production
- Better security with smaller attack surface
- Separation of build and runtime concerns
Image Size Optimization Techniques
These techniques have helped me reduce image sizes:
Use Alpine-based images
FROM node:14-alpine # Much smaller than node:14
Clean up in the same RUN step
RUN apt-get update && \
apt-get install -y something && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
Remove unnecessary files
RUN npm install && \
npm cache clean --force
Don't install dev dependencies in production
RUN npm install --only=production
Consider distroless images for even smaller images
FROM gcr.io/distroless/nodejs
Use .dockerignore to exclude unnecessary files from the build context
Distroless Images
Distroless images are minimalist Docker images that contain:
- Your application
- Its runtime dependencies
- Nothing else
Unlike regular base images like Ubuntu or Alpine, distroless images don't contain:
- Package managers (apt, apk, npm)
- Shells (bash, sh)
- Standard Linux utilities (ls, cp, vim)
Example of a distroless Node.js image:
FROM node:14 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM gcr.io/distroless/nodejs:14
COPY --from=builder /app/dist /app
WORKDIR /app
CMD ["index.js"]
Benefits:
- Drastically smaller image size
- Much smaller attack surface
- Better security (no shell access)
- Only what's needed to run the application
The downside is you can't easily debug inside these containers (no shell), but that's actually a security feature in production.
I use distroless images for production deployments when security is a priority and debugging inside the container isn't needed.
Conclusion
Working effectively with Docker images has made my development workflow much smoother. I can quickly create consistent environments, share them with teammates, and deploy them confidently.
The key lessons I've learned:
- Use specific tags for consistency
- Structure Dockerfiles for efficient caching
- Leverage multi-stage builds for smaller images
- Follow best practices for security and optimization
Next up, I'll cover Docker volumes and data persistence - a crucial topic for stateful applications.
Next up: "Docker Volumes and Data Persistence"
Top comments (0)