From Dockerfile to Kubernetes in Four Steps
By OpsAtlas · · 4 min read
Table of contents
In this tutorial you take a small Node.js API, notes-api, from a first Dockerfile to Kubernetes manifests that pass a security review. Each step uses one OpsAtlas tool and hands its result to the next:
- Lint the Dockerfile and apply the safe fixes.
- Turn the
docker runcommands intocompose.yamlfor local development. - Generate the Kubernetes Deployment and Service.
- Validate the manifests before you apply them.
Everything runs in your browser, so you can follow along with your own app instead of the example. The outputs shown below are exactly what the tools produce for the example inputs.
You need: a Node.js app with a package.json and package-lock.json, an image registry you can push to, and a cluster for step 4 if you want to deploy for real.
Step 1: Lint and fix the Dockerfile
Start with the Dockerfile most people write first:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD npm start
Paste it into the Dockerfile linter. It scores 79/100 (B) with five findings. Three of them have an Apply fix button:
- Runs as root: Add USER 10001 before CMD
- Shell-form CMD: Use exec form:
CMD ["npm", "start"] - npm install: Use npm ci
Click all three. The score rises to 98/100 (A), and the changed lines are highlighted in the editor. Two findings remain because they need a decision from you:
COPY . .before installing dependencies. Every code change reinstalls all packages. Copypackage.jsonandpackage-lock.jsonfirst, install, then copy the rest.- No
HEALTHCHECK. This one is informational: on Kubernetes the probes from step 3 do the same job.
Make those edits by hand. While you are at it, switch to the smaller node:22-slim base, install production dependencies only, and start Node directly so it receives SIGTERM:
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER 1000
HEALTHCHECK CMD ["node", "healthcheck.js"]
CMD ["node", "server.js"]
This scores 100/100. Notice USER 1000 rather than USER node. Both are the same non-root user in the official Node.js images, but Kubernetes can only check runAsNonRoot against a numeric user. With a user name, the pod in step 3 would fail to start.
Build and push the image as ghcr.io/acme/notes-api:1.0.0 (use your own registry and version).
Step 2: Replace docker run with Compose
Locally, the API needs a Postgres database. If you have been starting both with docker run, paste the commands into the docker run to Compose converter:
docker run -d --name notes-api --network notes -p 3000:3000 \
-e DATABASE_URL=postgres://notes:notes@db:5432/notes \
--restart unless-stopped ghcr.io/acme/notes-api:1.0.0
docker run -d --name db --network notes \
-e POSTGRES_USER=notes -e POSTGRES_PASSWORD=notes \
-v pgdata:/var/lib/postgresql/data \
--restart unless-stopped postgres:16
It produces this compose.yaml:
services:
notes-api:
image: ghcr.io/acme/notes-api:1.0.0
container_name: notes-api
networks:
- notes
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://notes:notes@db:5432/notes
restart: unless-stopped
db:
image: postgres:16
container_name: db
networks:
- notes
environment:
- POSTGRES_USER=notes
- POSTGRES_PASSWORD=notes
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
pgdata: {}
networks:
notes: {}
The converter also warns that Compose will create the notes network itself. If you created it earlier with docker network create, mark it external: true or delete the old one. Run docker compose up -d, check http://localhost:3000, and the local setup is done. The database password is fine for local development; in the cluster it belongs in a Secret.
Step 3: Generate the Kubernetes manifests
Open the Kubernetes manifest generator and fill in the form:
| Field | Value |
|---|---|
| App name | notes-api |
| Namespace | notes |
| Container image | ghcr.io/acme/notes-api:1.0.0 |
| Replicas | 2 |
| Container port | 3000 |
| Service port | 80 |
| Environment variables | NODE_ENV=production |
| Readiness and liveness probe | HTTP |
| Probe path | /healthz |
| Hardened security context | on |
Keep the default resources (100m CPU and 128Mi memory requested, 256Mi memory limit) unless you already know what the API needs. The generator writes a Deployment and a Service. The part that matters most is the hardened security context:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: notes-api
image: ghcr.io/acme/notes-api:1.0.0
# … ports, env, resources, probes …
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
The generator shows one warning: hardened mode requires an image that runs as a non-root user. Your image from step 1 already does, with a numeric USER, so runAsNonRoot: true passes. The read-only root filesystem is why the generator mounts an empty /tmp; if your app writes anywhere else, mount a volume there too.
Copy the output to k8s/notes-api.yaml.
Step 4: Validate before you apply
Paste the generated file into the Kubernetes YAML validator. It scores 100/100 (A). The diagram shows the Service selecting the Deployment, and there is one informational finding: no PodDisruptionBudget protects the two replicas during node drains. Add one when the service matters:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: notes-api
namespace: notes
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: notes-api
Paste it below the other documents and the validator links it to the Deployment. Then apply everything and check that both pods become ready:
kubectl create namespace notes
kubectl apply -f k8s/
kubectl -n notes rollout status deployment/notes-api
What you built
- A Dockerfile that installs only production dependencies, reuses the dependency cache when code changes, runs as a numeric non-root user and stops cleanly.
- A
compose.yamlthat replaces thedocker runcommands for local development. - Kubernetes manifests with probes, resource requests, a hardened security context and a disruption budget, reviewed before they reach the cluster.
To go further, read Dockerfile mistakes that leak secrets and bloat images, and move the database password into a Secret before your first real deploy.
Try it yourself
Dockerfile linter and fixer
Lint a Dockerfile in your browser: find root users, unpinned images, leaked secrets and bloated layers, then apply safe fixes in one click.
DockerBetadocker run to Docker Compose converter
Convert docker run commands into a clean compose.yaml: ports, volumes, env, networks, healthchecks and limits. Runs in your browser.
DockerKubernetes manifest generator
Generate a Kubernetes Deployment and Service with resources, probes and a hardened securityContext, plus optional Ingress and HPA.
KubernetesKubernetes YAML validator and visualizer
Paste Kubernetes manifests to get a resource diagram and a security and best-practice review: privileged pods, root, probes, limits, deprecated APIs.
KubernetesBeta