Most Docker tutorials stop at "Hello World" on a laptop. The hard part comes after: a database that keeps its data, HTTPS, a server firewall that Docker quietly walks around, logs that fill the disk, and backups you can actually restore. This guide takes one small app all the way from your first container to a live deployment on a VPS. Every command, Dockerfile and Compose file below was run end to end on 28 September 2026 with Docker Engine 29.4 and Docker Compose v5.1, using the images node:24-alpine, postgres:18-alpine and caddy:2-alpine.
What Docker Actually Does, in Five Words
Docker packages an application together with everything it needs to run, such as the language runtime, libraries and settings, so it behaves the same on your laptop and on a server. You only need five words to follow the rest of this guide.
- Image. A read-only package of your app and its runtime, built from a recipe called a Dockerfile.
- Container. A running copy of an image. You can start, stop, delete and recreate containers freely, because nothing important should live inside one.
- Volume. Storage that survives when a container is deleted. Databases keep their files here.
- Network. Containers started by the same Compose file can reach each other by service name, so your app connects to a host called
db. - Registry. Where images are published and downloaded from. Docker Hub is the default.
Unlike a virtual machine, a container does not boot its own operating system. It shares the host's Linux kernel, which is why containers start in seconds. On Windows and macOS, Docker Desktop runs that Linux kernel for you in a small virtual machine.
Install Docker and Run Your First Container
On Windows or macOS, install Docker Desktop from docker.com. On an Ubuntu server, Docker's documentation lists Ubuntu 22.04, 24.04 and 26.04 LTS as supported and offers a convenience script (Docker docs). Read any script before running it as root; the --dry-run flag shows what it would do.
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh ./get-docker.sh --dry-run
sudo sh ./get-docker.sh
sudo docker run hello-world
Now run a real web server in one line:
docker run --rm -p 8080:80 nginx:alpine
Open http://localhost:8080 and you will see the "Welcome to nginx!" page. The -p 8080:80 part means "port 8080 on my machine goes to port 80 inside the container". The host port comes first. --rm deletes the container when you stop it with Ctrl+C.
Package Your Own App: a Dockerfile Line by Line
Our example is a tiny Node.js app that counts visits in PostgreSQL. It is small enough to read in a minute and real enough to need a database, which is where most beginners get stuck. Create a folder with four files.
package.json:
{
"name": "visit-counter",
"version": "1.0.0",
"private": true,
"main": "server.js",
"dependencies": {
"pg": "^8.16.0"
}
}
server.js:
const http = require('node:http');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const port = process.env.PORT || 3000;
http.createServer(async (req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
return res.end('ok');
}
try {
await pool.query('CREATE TABLE IF NOT EXISTS visits (id serial PRIMARY KEY, at timestamptz DEFAULT now())');
await pool.query('INSERT INTO visits DEFAULT VALUES');
const { rows } = await pool.query('SELECT count(*) AS n FROM visits');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`Hello from Docker. Visits so far: ${rows[0].n}\n`);
} catch (err) {
console.error(err);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Database not reachable\n');
}
}).listen(port, () => console.log(`Listening on ${port}`));
Dockerfile:
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
.dockerignore:
node_modules
.git
.env
*.log
*.sql
backups/
What each Dockerfile line does, and why it is there:
FROM node:24-alpine. Start from the official Node.js 24 image on Alpine Linux. Node 24 is an LTS line on the Node.js release page (check there for its current status), and the Node project recommends only LTS lines for production. Never uselatestin production: it changes under you.COPY package*.jsonbeforeCOPY . .. Docker caches each step. Copying the dependency list first meansnpm installonly re-runs when dependencies change, not every time you edit a line of code.--omit=dev. Skips development-only packages, so the image is smaller and has less to patch.USER node. The official Node image ships a user callednode. Running as that user rather than root limits the damage if the app is ever compromised. We checked:docker compose exec app whoamiprintsnode..dockerignore. Keeps your localnode_modules, Git history, database dumps and, most importantly, your.envsecrets out of the image.
Build and run it on its own:
docker build -t visit-counter .
docker run --rm -p 3000:3000 visit-counter
http://localhost:3000/health answers ok, but the home page says Database not reachable and the logs show ECONNREFUSED. That is expected: there is no database yet. Wiring several containers together is exactly what Docker Compose is for.
Add a Database With Docker Compose
Create compose.yaml in the same folder. Note that there is no version: line at the top. Older tutorials include one, and current Compose prints a warning that "the attribute `version` is obsolete, it will be ignored".
services:
app:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
ports:
- "127.0.0.1:3000:3000"
depends_on:
db:
condition: service_healthy
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
Put the password in a file called .env next to it. Compose reads it automatically, and the .dockerignore above keeps it out of the image. Use a long random value; letters, digits and hyphens are safest because the password sits inside a URL.
DB_PASSWORD=change-me-to-something-long
Then start everything:
docker compose up -d --build
docker compose ps
curl http://localhost:3000
Our run printed Hello from Docker. Visits so far: 1, then 2 on the next request. Four details in that file save beginners hours:
- The app waits for a healthy database.
depends_onwithcondition: service_healthyholds the app back untilpg_isreadysucceeds. A plaindepends_ononly waits for the container to start, not for Postgres to accept connections. - The volume path changed in Postgres 18. The official image now keeps data in a version-specific folder under
/var/lib/postgresql, and its documentation says mounts should target that path (postgres on Docker Hub). Tutorials written for Postgres 16 or 17 mount/var/lib/postgresql/data, which does not match the 18 image. - The password is only set once. According to the same page, the
POSTGRES_*variables "only have an effect if you start the container with a data directory that is empty". Changing.envlater does not change the password of an existing database. - The data survives. We ran
docker compose downandup -dagain and the counter continued from where it stopped.docker compose down -vis different: the-vdeletes the volume and every row with it.
Deploy It to a VPS With Automatic HTTPS
On the server, install Docker as above, copy your project folder across (a git clone is cleanest), create the .env file there by hand, and point your domain's DNS A record at the server's IP address. Then add a reverse proxy in front of the app. We use Caddy because, in its own words, it "provisions TLS certificates for all your sites and keeps them renewed" and redirects HTTP to HTTPS, provided DNS points at the server and ports 80 and 443 are open (Caddy docs).
Create a file called Caddyfile, replacing the domain with yours:
app.example.com {
reverse_proxy app:3000
}
And extend compose.yaml for production:
services:
app:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
logging:
driver: local
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
logging:
driver: local
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
logging:
driver: local
volumes:
pgdata:
caddy_data:
caddy_config:
Run docker compose up -d --build once more. We validated this Caddyfile with caddy validate (result: "Valid configuration") and ran the same stack locally with Caddy serving plain HTTP, and the request passed through Caddy to the app and the database. Notice what changed from the local version:
- Only Caddy publishes ports. The app and the database have no
ports:at all. Caddy reaches the app over the internal Compose network, and nothing on the internet can reach Postgres directly. caddy_datais a volume. Caddy stores its certificates there. Without it, every recreate would request new certificates.restart: unless-stopped. Containers come back after a crash or a server reboot, unless you stopped them yourself.
A container you can delete and recreate without a second thought is the goal. Everything that must survive belongs in a volume, a backup or the .env file, never inside the container.
The Firewall Trap and Other Production Settings
- Docker bypasses UFW. Docker's documentation says that when you publish a port, traffic "gets diverted before it goes through the ufw firewall settings" (Docker docs). A
ufw deny 5432rule does not protect a database published with-p 5432:5432. The fix is to not publish internal services at all, as in the production file above, or to bind them to the host only with127.0.0.1:as in the local file. Docker's port-publishing page states that with127.0.0.1"only the Docker host can access the published container port" (Docker docs). - Logs grow forever by default. Docker's default
json-filelog driver performs no rotation, and Docker recommends thelocaldriver because it "performs log-rotation by default" (Docker docs). That is why every service above setsdriver: local. - The docker group equals root. Adding your user to the
dockergroup saves typingsudo, but Docker's own post-install guide warns that the group "grants root-level privileges to the user" (Docker docs). Only add people you would trust with root. - Build for the server's processor. An image built on an Apple Silicon Mac is arm64, while most VPS plans are x86-64. Either build on the server, or build for it explicitly:
docker buildx build --platform linux/amd64 -t visit-counter --load .
Backups, Updates and Clean-Up
A volume is not a backup. If the disk fails or someone runs down -v, it is gone. Dump the database to a file and copy that file off the server:
mkdir -p ../backups
docker compose exec -T db pg_dump -U app app > ../backups/backup-$(date +%F).sql
The dump goes into a folder outside the project on purpose: the Dockerfile's COPY . . would otherwise bake your whole database into the next app image (the *.sql line in .dockerignore is a second guard).
To restore into an empty database, feed the file back in:
docker compose exec -T db psql -U app -d app < ../backups/backup-2026-09-28.sql
We tested both: after deleting the table, the restore brought every row back and the counter continued from the restored count. The -T flag switches off the pseudo-terminal that exec allocates by default, which is what you want whenever a file is piped in or out.
To ship a new version of your code, or pick up patched base images:
git pull
docker compose pull
docker compose build --pull
docker compose up -d
docker compose pull refreshes the images you use as-is (Postgres, Caddy). Your app is built, not pulled, so build --pull is what fetches the latest node:24-alpine before rebuilding; a plain --build would reuse the base image already cached on the server.
Compose recreates only the containers whose image or configuration changed. Old images pile up over time; docker system df shows how much space they use and docker image prune removes the unused ones.
When Something Goes Wrong
| What you see | Likely cause | What to do |
|---|---|---|
Bind for 0.0.0.0:8080 failed: port is already allocated | Another container or program already uses that host port. | Stop the other container (docker ps shows it) or change the host side of -p, for example -p 8081:80. |
| A container keeps restarting or exits at once | The app crashed on start: a missing variable, a typo, a bad command. | Read docker compose logs --tail 50 app. The error is almost always in the last lines. |
| App says the database is not reachable | Wrong host name or password in DATABASE_URL, or the database is still starting. | The host must be the service name (db), not localhost. Check docker compose ps shows the database as healthy. |
Password change in .env has no effect | Postgres only applies POSTGRES_PASSWORD to an empty data directory. | Change it inside the database with ALTER USER, then update .env to match. |
| Permission denied on the Docker socket | Your user is not allowed to talk to the Docker daemon. | Use sudo, or add the user to the docker group knowing it is root-equivalent. |
| Disk suddenly full | Old images, build cache or unrotated logs. | Check docker system df, prune unused images, and switch logging to the local driver. |
The Commands You Will Use Every Week
| Command | What it does |
|---|---|
docker compose up -d --build | Build what changed and start everything in the background. |
docker compose ps | List the stack's containers, their health and ports. |
docker compose logs -f app | Follow one service's logs live. Ctrl+C to stop following. |
docker compose exec db psql -U app -d app | Open a database shell inside the running container. |
docker compose down | Stop and remove the containers. Volumes and data stay. |
docker compose down -v | Also delete the volumes. Your data goes with them. |
docker system df | Show disk used by images, containers, volumes and cache. |
Where to Go Next
One server running Docker Compose is enough for a surprising number of business applications. Before reaching for Kubernetes, get the basics above boring and reliable: backups copied off the server and tested, updates on a schedule, and monitoring that tells you when the site is down before a customer does.
If your app exposes an API, our comparison of REST and GraphQL helps you choose its shape. If you would rather hand the server work to someone else, our server management service covers VPS setup and hardening, firewalls, Nginx and SSL, backups and monitoring, and our web development team can build the application itself. Questions about your own setup? Talk to us.