Optimization & Security when writing your own Dockerfile by AP.xyz

- Primary stack: Nuxt3 + Bun.js on Alpine Linux
 - Start by configuring 
.dockerignoreto exclude unnecessary files and any sensitive data from the build context - Use a multi-stage build to separate build steps and minimize the final image size
 - Finish with a scratch image (instead of Distroless) to tightly control exactly what gets copied in
 - Create a non-root user/group inside the container for security
 - Copy only the essential libraries and executables** (`
bun`, `libstdc++`, `libgcc_s`, `ld-musl`) - Reason for not using Distroless: it’s based on glibc, while Alpine uses musl
 
- This approach isn’t “the one true way”—it’s what I learned and improved over time
 
---
Hello everyone! I’m back to blogging after a stretch in the DevOps trenches. I hope this post helps anyone exploring Dockerfiles.
A good Dockerfile copies only what your app needs to run—no credentials, env files, or other sensitive artifacts in the image or build context. (If you copy creds into a container, consider that a friendly wrist-slap from me .)
I only started getting nerdy about Dockerfiles in the last 4 years. I used to just copy “whatever works” to get it deployed. Later, I learned what not to copy, moved to multi-stage builds, tried Distroless, and eventually landed on scratch images.
In this post I’ll show my personal approach to a lightweight, secure, and clean Dockerfile, based on my own personal website project.
Stack to containerize
• Code base: Nuxt3 (Vue 3) + Bun.js
• Base OS: Alpine Linux
Folder Structure
(Your Nuxt3 structure image here)
Notice .dockerignore—this is your best friend for keeping unneeded files and sensitive objects out of your build context.
(Your .dockerignore image here)
I exclude local build outputs and anything not required for builds: node_modules, .output, .nuxt, .git, as well as sensitive files like .env, and editor artifacts (.vscode, .idea). I also exclude deployment scripts and manifests (e.g., Helm charts, Kubernetes YAMLs) if they’re not required in the image.

Dockerfile
FROM oven/bun:alpine as base  # Choose the distro we’ll use as the build base
# Copy only what’s needed for dependency install
COPY package.json bun.lock ./app/
WORKDIR /app  # Set the working directory
RUN bun install --frozen-lockfile  # Install deps for building Nuxt
# ---------- Build stage ----------
FROM base AS prerelease
# Create a dedicated, non-root user
ENV USER_NAME=isaac
ENV UID=1001
ENV GID=1001
# Install minimal packages and create user/group
RUN apk update && \
    apk add --no-cache ca-certificates && \
    addgroup -g ${GID} ${USER_NAME} && \  # Create group
    adduser -D -u ${UID} -G ${USER_NAME} ${USER_NAME} && \  # Create user and add to group
    chown -R ${USER_NAME}:${USER_NAME} /app  # Own /app
USER ${USER_NAME}  # Drop privileges
# Reuse node_modules from the base stage
COPY --from=base /app/node_modules node_modules
# Copy the rest of the app (excluding items filtered by .dockerignore)
COPY . .
ENV NODE_ENV=production  # Build in production mode
RUN bun run build  # Build Nuxt (.output)
# ---------- Final minimal runtime ----------
FROM scratch  # Ultra-minimal image with only what we copy in
# Copy required user/group files so the non-root user works at runtime
COPY --from=prerelease /etc/passwd /etc/passwd
COPY --from=prerelease /etc/group /etc/group
ENV USER_NAME=isaac
USER ${USER_NAME}
WORKDIR /app
# Copy Bun runtime and essential runtime libs
COPY --from=base /usr/local/bin/bun /usr/local/bin/bun
COPY --from=base "/usr/lib/libstdc++.so.6" "/usr/lib/libstdc++.so.6"
COPY --from=base /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1
# Dynamic linker (must match architecture):
#   - x86_64: /lib/ld-musl-x86_64.so.1
#   - arm64:  /lib/ld-musl-aarch64.so.1
COPY --from=base /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
# Copy built output (ensure ownership matches the runtime user)
COPY --from=prerelease --chown=${USER_NAME}:${USER_NAME} /app/.output /app
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
EXPOSE 300
CMD ["bun", "run", "server/index.mjs"]
The Dockerfile above is from my actual project. Each section is commented inline.
Why scratch?
Primarily to prevent shell execution and tightly control what’s inside the image. Yes, it’s a bit more work (copying the exact libs and the correct musl loader for your architecture), but it’s explicit and secure.
Why not Distroless here?
Distroless images are glibc-based, while Alpine uses musl. For this Alpine-based stack, scratch gave me clearer control and fewer surprises.
This is just my approach—it may not be the best for every team or workflow. I’m sharing what I’ve learned, the weaknesses I hit, and how I addressed them. I welcome constructive feedback from the Developer / DevOps / Security community.
God bless, and happy shipping!

