Key Takeaway
Learning Docker from zero takes six steps: write a Dockerfile, run docker build to create an image, run docker run with the -p flag to start a container and publish a port, inspect it with docker ps, docker logs and docker exec, then keep data in a volume so it survives when the container is removed.
You installed Docker, typed docker run, and the terminal printed one long line of random characters. You opened the browser and localhost does not answer. You ran the command again and now an error says the container name is already in use. Nearly everyone learning Docker from zero stalls here, not because Docker is hard, but because nobody explained what each command actually does.
This guide builds one small Node.js app and takes it from a Dockerfile to a container whose data survives deletion. I checked every command against the official Docker documentation, and the Dockerfile pattern is the same one this site uses itself. The output shown is only what the code below is certain to print; IDs and timestamps will differ on your machine.
Prefer the animated version? Our free lesson explains layers and the build cache visually: Docker for beginners: image, container and Dockerfile. This article is the hands-on side: you type, run and see the result yourself.
What you need
Not much. Every step below works on Linux, macOS and Windows as long as Docker is running.
- Docker installed and running (Docker Desktop on macOS or Windows, Docker Engine on Linux).
- A terminal and any text editor. You do not need Node.js on your own machine, because Node.js lives inside the image.
- About 30 minutes, and an empty folder named belajar-docker.
docker --version
# Expect one line starting with: Docker version ...
# "command not found" or "cannot connect to the Docker daemon" means
# Docker is not installed or not running yet. Fix that first.Check that Docker is alive first. If the command above fails, fix that before going on; the steps that follow cannot work without a running Docker daemon.
Step 1: Understand image and container through three commands
An image is a read-only package holding your application and everything it needs. A container is an image that is currently running. One image can become many containers, the same way one installer file can be launched many times. The easiest way to remember it: the command you type tells you which one you are dealing with.
| You want to | Command | Result |
|---|---|---|
| Create an image from a Dockerfile | docker build | An image named belajar-docker:v1 |
| Run an image | docker run | A running container |
| See what is running | docker ps | A list of containers, not images |
If you get lost in the steps ahead, come back to this table. The Dockerfile and the image live on the build side; containers and ports live on the run side.
Step 2: Write a tiny app and a Dockerfile
Create two files in the belajar-docker folder. The app counts how many times it was visited and keeps the number in /data/count.txt. We use that counter in Step 6 to see why container data can vanish.
// belajar-docker/server.js
const http = require("node:http");
const fs = require("node:fs");
const FILE = "/data/count.txt";
http
.createServer((req, res) => {
let n = 0;
try {
n = Number(fs.readFileSync(FILE, "utf8")) || 0;
} catch {
// first visit: the file does not exist yet
}
n += 1;
fs.writeFileSync(FILE, String(n));
console.log("visit " + n);
res.end("Kunjungan ke-" + n + "\n");
})
// 0.0.0.0, not 127.0.0.1: inside a container, 127.0.0.1 is the container
// itself, so a port you publish could never reach the server.
.listen(3000, "0.0.0.0");# belajar-docker/Dockerfile
FROM node:22-alpine
WORKDIR /app
# /data is where the counter file lives; step 6 mounts a volume here.
RUN mkdir /data
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]Read the Dockerfile top to bottom: start from the node:22-alpine base image, move to the /app folder, copy server.js, note that the app uses port 3000, then set the command that runs when the container starts. The reference stresses that EXPOSE only documents a port and does not publish it; publishing is the job of docker run in Step 4.
# Wrong: single quotes. The reference requires double quotes in the JSON array.
CMD ['node', 'server.js']
# Right:
CMD ["node", "server.js"]This site's own Dockerfile uses the same pattern: WORKDIR /app, port 3000 inside the container, and CMD in JSON array form with double quotes. The difference is that this site's Dockerfile has several stages so the final image stays small. That is a more advanced topic, covered in the best practices article linked at the end.
Step 3: Build your first image
Run docker build from inside the belajar-docker folder. The dot at the end of the command is not decoration: it is the build context, the folder sent to Docker. Forgetting the dot is one of the first errors beginners hit.
cd belajar-docker
# -t gives the image a name:tag. The trailing "." is the build context:
# the current folder, which is sent to Docker.
docker build -t belajar-docker:v1 .
docker image ls belajar-docker
# Expect one row: REPOSITORY belajar-docker, TAG v1While it builds, you will see one progress step for each Dockerfile instruction. The first time, Docker downloads the node:22-alpine image, so it takes longer. Run the same command again and Docker reuses its cache for the unchanged steps, which is why the second build is much faster.
Step 4: Run the container and publish its port
docker run turns the image into a container. The three flags below are the ones you will use most. -d runs the container in the background and prints its ID, --name gives you a name that is easy to type, and -p connects a port on your machine to a port inside the container.
# -d run in the background and print the container ID
# --name a name you can type instead of the random ID
# -p HOST:CONTAINER traffic to host port 8080 goes to container port 3000
docker run -d --name belajar-app -p 8080:3000 belajar-docker:v1
curl http://localhost:8080
# Kunjungan ke-1
curl http://localhost:8080
# Kunjungan ke-2# Wrong: host and container swapped. Nothing listens on 8080 inside the container.
docker run -d --name belajar-app -p 3000:8080 belajar-docker:v1
# Right: host side first, container side second.
docker run -d --name belajar-app -p 8080:3000 belajar-docker:v1
# Same thing, but only this machine can reach it (what this site does on its VPS).
docker run -d --name belajar-app -p 127.0.0.1:8080:3000 belajar-docker:v1The order is always host port first, then container port. Without an IP address, Docker publishes the port on all interfaces, meaning it can be reached from outside your machine. That is why this site on its VPS uses the 127.0.0.1 form in front of the port, so only nginx on the same machine can reach it.
If your app listens on 127.0.0.1 inside the container, the port you publish will never reach it, because 127.0.0.1 there means the container itself. Listen on 0.0.0.0 inside, then restrict outside access with -p 127.0.0.1:8080:3000. This site's Dockerfile also sets HOSTNAME to 0.0.0.0 for the same reason.
Step 5: Look inside with ps, logs and exec
These three commands are your everyday debugging tools. docker ps shows running containers, docker logs shows what the app printed, and docker exec runs a new command inside a live container. The -it flags on exec give you an interactive terminal; the docker exec documentation uses sh as its example, and sh is available in Alpine images.
docker ps
# Columns: CONTAINER ID, IMAGE, COMMAND, CREATED, STATUS, PORTS, NAMES
# One row: belajar-app, STATUS Up ...
docker ps -a # -a also lists stopped containers
docker logs belajar-app
# visit 1
# visit 2
docker logs -f --tail 20 belajar-app # -f follows, Ctrl+C to stop following
docker exec -it belajar-app sh
cat /data/count.txt
# 2
exitTwo habits matter. First, if a container is missing from docker ps, add -a before concluding it vanished; it may simply have stopped. Second, docker exec only works while the container's main process is running, so for a crashed container, read docker logs instead.
Step 6: Keep data with a volume
Everything the app writes inside a container lands in that container's own writable layer. Remove the container and the data goes with it. Try it yourself: the sequence below removes the container and starts it again, and the counter resets to 1.
# 1. Without a volume: the counter dies with the container.
docker rm -f belajar-app
docker run -d --name belajar-app -p 8080:3000 belajar-docker:v1
curl http://localhost:8080
# Kunjungan ke-1 <- the count started over
# 2. With a named volume mounted at /data.
docker rm -f belajar-app
docker run -d --name belajar-app -p 8080:3000 -v belajar-data:/data belajar-docker:v1
curl http://localhost:8080
# Kunjungan ke-1
curl http://localhost:8080
# Kunjungan ke-2
docker rm -f belajar-app
docker run -d --name belajar-app -p 8080:3000 -v belajar-data:/data belajar-docker:v1
curl http://localhost:8080
# Kunjungan ke-3 <- the number survived the containerThe fix is a volume. The Docker documentation says a volume's contents exist outside the lifecycle of a given container, so the data stays even when the container is removed. The form -v volume-name:/path mounts a named volume at a folder inside the container; Docker creates the volume automatically if it does not exist yet.
# Clean up when you are done
docker rm -f belajar-app
docker volume rm belajar-dataThis is exactly the pattern this site uses in production: its compose file mounts a named volume called next-image-cache so the optimised image cache is not lost every time the container is replaced on deploy. Note also that docker rm does not delete a named volume; you have to run docker volume rm yourself.
Common errors and how to fix them
These six symptoms eat most of a beginner's time. Match the message in your terminal to the first column.
| Symptom | Cause | Fix |
|---|---|---|
| Message bind failed: port is already allocated | Host port 8080 is already used by another container, including an old one you never removed. | Check docker ps -a, remove the old one with docker rm -f name, or use another host port such as -p 8081:3000. |
| Message Conflict: the container name is already in use | An old container, even a stopped one, still holds the name belajar-app until it is removed. | docker rm -f belajar-app, then run docker run again. |
| curl refuses the connection or it drops | Port mapping swapped (-p 3000:8080), or the app listens on 127.0.0.1 inside the container. | Write -p host-port:container-port, and make the app listen on 0.0.0.0. |
| The container shows Exited right away in docker ps -a | The main process finished or crashed. A container lives only as long as its main process. | Read docker logs name and fix the error; also check the CMD in the Dockerfile. |
| Cannot connect to the Docker daemon | Docker Desktop or the Docker service is not running. | Open Docker Desktop, or on Linux start the Docker service, then try docker --version and docker ps again. |
| Data or the counter resets after the container is removed | The data was written to the container's writable layer, not to a volume. | Add -v volume-name:/path to docker run, as in Step 6. |
The general rule: when a container misbehaves, run docker ps -a and then docker logs name before changing anything. Nearly every answer is in those two commands.
Checklist before moving on
- I can explain that docker build creates an image and docker run creates a container.
- I know the order of -p is host port first, then container port.
- I can use docker ps -a, docker logs and docker exec -it to look inside a container.
- I understand data without a volume is lost when the container is removed.
- I cleaned up with docker rm -f belajar-app and docker volume rm belajar-data.
Go deeper
You now have a first container. To understand why the order of Dockerfile instructions decides build speed, open the free lesson Docker for beginners: image, container and Dockerfile. After that, read Docker containerization best practicesto tidy the image before it goes to a server, and Docker Compose Watch for local developmentonce you are tired of rebuilding the image every time you change one line of code.
One rule to carry with you: an image is what you build, a container is what you run, and anything that matters must live in a volume, not inside the container. From here, Docker Compose is the natural next step.

