You’ve got a server running. You could install services directly on it, like Nginx or Postgres. But that gets messy. Different apps need different versions of the same dependency. Uninstalling leaves junk behind. And trying something new means risking what already works.
Docker fixes that. Each service runs in its own isolated container. Want to try something? Spin it up. Don’t like it? docker rm and it’s gone.
Before You Start
You need Docker installed. On Ubuntu Server:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
Log out and back in for the group change to take effect. Then test it:
docker run hello-world
You should see a welcome message. Permission error means you forgot to log back in.
Method 1: Docker Compose
This is what you’ll use day-to-day. You define everything in a YAML file: what image, what ports, what folders to mount, what environment variables to set. One command and it all runs.
Your First Compose File
mkdir ~/docker-services
cd ~/docker-services
Create docker-compose.yml:
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./html:/usr/share/nginx/html
restart: unless-stopped
Make a page to serve:
mkdir html
echo "<h1>My homelab is alive</h1>" > html/index.html
Start it:
docker compose up -d
Open http://your-server-ip in a browser and you should see the page.
The -d flag means detached, so it runs in the background. Without it, logs fill your terminal.
Adding More Services
Add a database to the same file:
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./html:/usr/share/nginx/html
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
pgdata:
Run docker compose up -d again. Compose notices the new service and only starts Postgres. Nginx stays up because it didn’t change. You keep editing the file and re-running up -d.
Useful Compose Commands
docker compose up -d # Start everything
docker compose down # Stop and remove containers
docker compose logs -f # Follow logs from all services
docker compose ps # Show running services
docker compose restart # Restart everything
Persistent Data
Databases need storage that survives container restarts. In Compose you use volumes. Two types:
- Named volumes - Docker manages where the data lives. Good for databases.
- Bind mounts - You point to a specific directory. Good for config files, HTML, stuff you want to edit directly.
Bind mount example:
services:
my-app:
image: my-app:latest
volumes:
- ./config:/app/config
- ./data:/app/data
Tips
Keep everything in one docker-compose.yml at first. Split only when you have more than 5-6 services and it gets hard to find things.
Pin your image versions. nginx:latest will change under you. nginx:alpine won’t. Alpine variants are smaller - nginx:alpine is about 25MB vs 180MB for the full image.
Set restart: unless-stopped on services you want to keep running. Without it, Docker kills them on reboot.
Method 2: Plain Docker CLI
The CLI is faster for quick tests. No files to create. Just type and run.
Running a Container
docker run -d --name nginx -p 80:80 nginx:alpine
Breaking down the flags:
-d- detach (background)--name nginx- name so you can reference it later-p 80:80- map port 80 on your server to port 80 in the containernginx:alpine- the image
Check it:
docker ps
You should see the container with a status like “Up 2 minutes.”
Commands
docker ps # Running containers
docker ps -a # All containers (including stopped)
docker stop nginx # Stop by name
docker rm nginx # Remove a stopped container
docker logs nginx # Show logs
docker logs -f nginx # Follow logs
docker exec -it nginx sh # Open a shell inside the container
Port Mapping
Multiple web services can’t all use port 80. Map them to different host ports:
docker run -d --name app1 -p 8080:80 nginx:alpine
docker run -d --name app2 -p 8081:80 nginx:alpine
app1 is at http://your-server:8080. app2 is at http://your-server:8081.
Mounting Files
Containers are temporary. Remove one and its files go with it. Mount a directory to keep data around:
docker run -d --name nginx -p 80:80 -v /path/on/host:/path/in/container nginx:alpine
Real example:
mkdir ~/nginx-html
echo "<h1>Hello from Docker</h1>" > ~/nginx-html/index.html
docker run -d --name nginx -p 80:80 -v ~/nginx-html:/usr/share/nginx/html nginx:alpine
Edit files in ~/nginx-html and refresh the browser. Changes show instantly.
Environment Variables
Many images need env vars for configuration:
docker run -d \
--name postgres \
-e POSTGRES_PASSWORD=changeme \
-e POSTGRES_DB=myapp \
-v pgdata:/var/lib/postgresql/data \
postgres:16-alpine
The -e flag sets environment variables. Check the image’s docs to know which ones are required.
Cleaning Up
Containers and images pile up. Clean occasionally:
docker container prune # Remove all stopped containers
docker image prune # Remove unused images
docker system prune # Everything + networks, build cache
docker system prune is safe but aggressive. Skip it if you have stopped containers you might want to inspect.
Next Steps
You can run services. That’s the hard part. From here, look at Choosing Server Hardware if you want to upgrade,