March 05, 2026 • 11 min read

Docker for Beginners: From Zero to a Live App on Your Own Server

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 use latest in production: it changes under you.
  • COPY package*.json before COPY . .. Docker caches each step. Copying the dependency list first means npm install only 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 called node. Running as that user rather than root limits the damage if the app is ever compromised. We checked: docker compose exec app whoami prints node.
  • .dockerignore. Keeps your local node_modules, Git history, database dumps and, most importantly, your .env secrets 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_on with condition: service_healthy holds the app back until pg_isready succeeds. A plain depends_on only 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 .env later does not change the password of an existing database.
  • The data survives. We ran docker compose down and up -d again and the counter continued from where it stopped. docker compose down -v is different: the -v deletes 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_data is 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 5432 rule 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 with 127.0.0.1: as in the local file. Docker's port-publishing page states that with 127.0.0.1 "only the Docker host can access the published container port" (Docker docs).
  • Logs grow forever by default. Docker's default json-file log driver performs no rotation, and Docker recommends the local driver because it "performs log-rotation by default" (Docker docs). That is why every service above sets driver: local.
  • The docker group equals root. Adding your user to the docker group saves typing sudo, 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 seeLikely causeWhat to do
Bind for 0.0.0.0:8080 failed: port is already allocatedAnother 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 onceThe 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 reachableWrong 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 effectPostgres 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 socketYour 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 fullOld 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

CommandWhat it does
docker compose up -d --buildBuild what changed and start everything in the background.
docker compose psList the stack's containers, their health and ports.
docker compose logs -f appFollow one service's logs live. Ctrl+C to stop following.
docker compose exec db psql -U app -d appOpen a database shell inside the running container.
docker compose downStop and remove the containers. Volumes and data stay.
docker compose down -vAlso delete the volumes. Your data goes with them.
docker system dfShow 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.

Tags

Docker Docker for Beginners Docker Compose Dockerfile Deploy Docker to VPS PostgreSQL Docker Caddy HTTPS DevOps

Share this article

IO

IO Snack Team

Editorial Team, IO Snack

Written and reviewed by the IO Snack team — the engineers who build and run the CRM, ERP, POS and AI systems these guides describe. About IO Snack

Want This Working in Your Business?

Tell us what you are trying to fix and we will tell you which module does it — or whether you need one at all.

Need help with your project?

Chat with us on WhatsApp