Docker packages your application with its dependencies so it runs the same way on every server. Most of the "it worked on my machine" problem is solved here.
This guide covers a single-server setup on a VDS. Orchestration layers like Kubernetes are for systems spread across several machines; on one server, Docker Compose is more than enough.
Installation
The docker.io package in distribution repositories is usually outdated. Use the official one:
bashcurl -fsSL https://get.docker.com | sh
systemctl enable --now docker
docker --versionTo use it without root, add your user to the docker group:
bashusermod -aG docker $USERUnderstand that this group membership is equivalent to root: a user in docker can mount the host filesystem into a container and reach everything. Grant it only to users you trust.
Log out and back in for the change to apply.
Running a single container
bashdocker run -d --name web -p 8080:80 --restart unless-stopped nginx:alpineWhat the flags do: -d runs it in the background, -p 8080:80 maps port 8080 on the server to port 80 in the container, and --restart unless-stopped brings it back when the server reboots.
To check status and read logs:
bashdocker ps
docker logs -f webMulti-service applications with Compose
Real applications are not one container. A compose.yaml file defines the application and its database together:
textservices:
app:
image: ghcr.io/user/myapp:1.4.2
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:Bring it up with:
bashdocker compose up -d
docker compose psThree things deserve attention:
Bind the port to localhost only. Writing 127.0.0.1:3000:3000 keeps the application off the public internet; only the reverse proxy faces outward. Writing just 3000:3000 exposes the port to everyone regardless of your firewall.
Pin the tag. Use a specific version like 1.4.2 instead of latest, otherwise the next pull brings a version you did not expect.
Passwords do not belong in the file. ${DB_PASSWORD} is read from a .env file in the same directory; keep that file out of version control.
Data persistence
Containers are disposable; when one is removed, everything inside goes with it. Data that must survive belongs on a volume — db-data does that in the example above.
Backing up is not a matter of copying host files; take the database dump through the container:
bashdocker compose exec -T db pg_dump -U app app | gzip > backup-$(date +%F).sql.gzFor the full backup approach see our 3-2-1 guide.
HTTPS: the reverse proxy
The application container does not listen on 443 directly. Nginx on the host receives the request and passes it through:
textserver {
listen 80;
server_name app.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Then obtain the certificate with Certbot. We covered the Nginx setup in our Nginx guide.
Maintenance
bashdocker compose pull && docker compose up -d # update
docker system df # disk usage
docker system prune -a --volumes # CAREFUL: also deletes unused volumesThat last command reclaims disk but use it carefully: --volumes removes data volumes that are not currently attached to a container.
If you would like help sizing a Docker-based setup, get in touch.
