This guide walks you through a production-ready Docker setup for small projects on Ubuntu 24.04. By the end you'll have Docker Engine installed, a working docker-compose.yml for a typical web app with a database, a non-root user that can run containers, and a systemd-managed service that survives reboots — all without paying for Docker Desktop or a managed container platform.
Prerequisites
- Ubuntu 24.04 LTS server or workstation (fresh install or existing)
- A user account with
sudoprivileges - Outbound internet access to reach
download.docker.com - Basic familiarity with the terminal and a text editor (
nanoorvim) - Ports 80 and 443 free if you plan to expose a web app
Step 1 — Remove Conflicting Packages
Ubuntu 24.04 ships with stub Docker packages that conflict with the official Engine. Remove them first.
sudo apt-get remove -y docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc
Expected output: each package is listed as "not installed" or is removed without error. It is safe to run even on a clean system.
Step 2 — Add the Official Docker APT Repository
2a. Install the packages needed to add an HTTPS APT source.
sudo apt-get update
sudo apt-get install -y ca-certificates curl
2b. Download and store Docker's GPG key.
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
2c. Add the repository to your sources list.
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
This writes a single-line .list file pointing to the noble (24.04) stable channel.
Step 3 — Install Docker Engine and the Compose Plugin
Update the package index now that the new repo is registered, then install.
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Expected output ends with:
Setting up docker-ce (5:26.x.x-1~ubuntu.24.04~noble) ...
The docker-compose-plugin package provides the docker compose subcommand (V2). There is no separate docker-compose binary to manage.
Step 4 — Allow Your User to Run Docker Without sudo
Adding your user to the docker group avoids prefixing every command with sudo.
sudo usermod -aG docker $USER
Log out and back in (or run newgrp docker in the current shell) for the group membership to take effect.
newgrp docker
Security note: Members of the
dockergroup have effective root access to the host. On a shared server, usesudo dockerinstead and skip this step.
Step 5 — Enable Docker to Start on Boot
Enable and start the systemd units for Docker and containerd.
sudo systemctl enable --now docker containerd
Expected output:
Synchronizing state of docker.service with SysV service script with /usr/lib/systemd/systemd-sysv-install.
Executed /usr/lib/systemd/systemd-sysv-install enable docker
Step 6 — Create a Project Directory and a docker-compose.yml
This example sets up a small Node.js web app backed by PostgreSQL 16 — a common pattern for indie projects. Adjust the image names and ports to match your stack.
6a. Create the project directory.
mkdir -p ~/projects/myapp && cd ~/projects/myapp
6b. Create the Compose file.
nano docker-compose.yml
Paste the following content:
services:
web:
image: node:20-alpine
working_dir: /app
volumes:
- ./app:/app
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://appuser:changeme@db:5432/appdb
depends_on:
db:
condition: service_healthy
command: ["node", "server.js"]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: changeme
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:
Key decisions explained:
node:20-alpineandpostgres:16-alpineuse Alpine-based images — smaller pull size, lower attack surface.db_datais a named volume so database files survivedocker compose down.depends_onwithservice_healthyprevents the web container from starting before PostgreSQL is ready to accept connections.restart: unless-stoppedmeans containers come back after a server reboot without a separate systemd unit.
6c. Create a minimal placeholder app so the web container has something to run.
mkdir -p app
cat > app/server.js << 'EOF'
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200);
res.end('OK\n');
}).listen(3000, () => console.log('Listening on 3000'));
EOF
Step 7 — Set Secrets in an .env File (Not in the Compose File)
Hardcoding credentials in docker-compose.yml is fine for local dev, but even for small projects you should use an .env file and exclude it from version control.
7a. Create the .env file.
cat > .env << 'EOF'
POSTGRES_USER=appuser
POSTGRES_PASSWORD=changeme
POSTGRES_DB=appdb
EOF
7b. Update docker-compose.yml to reference the variables.
Replace the hardcoded environment block under db with:
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
And update the DATABASE_URL under web:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
7c. Add .env to .gitignore.
echo ".env" >> .gitignore
Docker Compose automatically reads .env from the same directory as the Compose file, so no extra flags are needed.
Step 8 — Pull Images and Start the Stack
Pull all images declared in the Compose file before starting, so you can catch network issues separately from startup issues.
docker compose pull
Then start the stack in detached mode.
docker compose up -d
Expected output:
[+] Running 3/3
✔ Network myapp_default Created
✔ Container myapp-db-1 Started
✔ Container myapp-web-1 Started
Verify It Works
Check container status. Both containers should show Up and db should show (healthy).
docker compose ps
Expected output:
NAME IMAGE COMMAND SERVICE STATUS PORTS
myapp-db-1 postgres:16-alpine "docker-entrypoint.s…" db Up (healthy) 5432/tcp
myapp-web-1 node:20-alpine "node server.js" web Up 0.0.0.0:3000->3000/tcp
Hit the web endpoint.
curl -s http://localhost:3000
Expected output:
OK
Confirm PostgreSQL is reachable from the web container.
docker compose exec web sh -c "apk add --no-cache postgresql-client -q && psql \"$DATABASE_URL\" -c 'SELECT 1;'"
Expected output includes:
?column?
----------
1
(1 row)
Confirm data persists after a restart.
docker compose down && docker compose up -d
curl -s http://localhost:3000
The db_data volume retains PostgreSQL data across restarts.
Troubleshooting
Got permission denied while trying to connect to the Docker daemon socket
You have not logged out after adding yourself to the docker group. Run newgrp docker or open a new terminal session.
port is already allocated on port 3000 or 5432
Another process is using that port. Find it with sudo ss -tlnp | grep 3000 and stop it, or change the host-side port in docker-compose.yml (e.g., "3001:3000").
myapp-web-1 exits immediately
The app crashed on startup. Inspect logs with docker compose logs web. Common cause: server.js missing or syntax error in the file.
db container stuck in (health: starting) for more than 60 seconds
Check logs with docker compose logs db. Likely causes: volume permission issue or the POSTGRES_PASSWORD variable is empty. Verify .env is present and non-empty.
Images fail to pull behind a corporate proxy
Set HTTP_PROXY and HTTPS_PROXY in /etc/systemd/system/docker.service.d/proxy.conf and run sudo systemctl daemon-reload && sudo systemctl restart docker.
docker compose command not found
You installed the legacy standalone docker-compose instead of the plugin. Verify with docker compose version. If missing, re-run Step 3 and confirm docker-compose-plugin is listed in apt-get install.
Next Steps
With your Docker setup for small projects running, consider these additions:
- Add a reverse proxy. Drop an
nginx:alpineorcaddy:alpineservice into the same Compose file to handle TLS termination and route traffic from port 443 to your app container. - Set up automated backups for the named volume. Run
docker run --rm -v myapp_db_data:/data -v $(pwd):/backup alpine tar czf /backup/db_backup.tar.gz /dataon a cron schedule. - Pin image digests for reproducibility. Replace
postgres:16-alpinewithpostgres:16-alpine@sha256:<digest>to prevent silent image updates from breaking your app. - Use Docker's BuildKit for custom images. When you outgrow off-the-shelf images, add a
build: .key to your service and aDockerfilein the project root. BuildKit is enabled by default in Docker Engine 23+. - Monitor container resource usage. Run
docker statsfor a live view of CPU and memory per container — useful for right-sizing your VPS before you scale.