Deploying with Docker Compose#
If you have a docker-compose.yml you are in luck: you have already described how your application runs. Three things are left - install Docker on the server, make the service persistent, and expose it safely.
1. Install Docker#
The Docker package in distribution repositories is usually old; use the official script.
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
Log out and back in for the group change to take effect, then verify:
docker --version
docker compose version
docker run --rm hello-world
With Morpheus
"install Docker and Docker Compose on my server"
docker compose, not docker-compose
The hyphenated form is the old v1 and is no longer supported. The modern one has a space: docker compose up. Do not be confused when older guides show the hyphen.
2. Get the project onto the server#
sudo mkdir -p /opt/myapp
sudo chown -R $USER:$USER /opt/myapp
cd /opt/myapp
git clone https://github.com/user/repo.git .
nano /opt/myapp/.env
chmod 600 /opt/myapp/.env
3. Bind ports to localhost#
This is the most important step on this page and the one most often skipped.
If your compose file says:
ports:
- "8080:8080"
the container listens on every network interface and anyone who reaches your server's IP hits it directly. Having a firewall usually does not help, because Docker writes its own iptables rules and bypasses ufw.
Docker bypasses ufw
This is a rule we learned from a real incident: a port that looks closed in ufw may have been opened by Docker and be reachable from the internet. For an attacker looking for a foothold, that is enough.
Fix: bind the port to the loopback interface only.
ports:
- "127.0.0.1:8080:8080"
Now only the server itself (that is, Nginx) can reach the container. Nginx becomes the single door to the outside.
Verify:
sudo ss -tlnp | grep 8080
You should see 127.0.0.1:8080, not 0.0.0.0:8080.
4. Bring it up#
cd /opt/myapp
docker compose up -d
docker compose ps
docker compose logs -f --tail=100
-d runs it in the background. You can stop following logs with Ctrl+C; the containers keep running.
5. Survive a reboot#
Add this to every service in your compose file:
restart: unless-stopped
That covers both crashes and reboots. Make sure Docker itself starts at boot:
sudo systemctl enable docker
Then actually test it - reboot and check:
sudo reboot
# once you can connect again:
docker compose -f /opt/myapp/docker-compose.yml ps
6. Expose it with Nginx#
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
}
}
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com
See steps 4 and 5 of the Next.js page for detail; the logic is the same.
Updates#
cd /opt/myapp
git pull
docker compose pull
docker compose up -d
docker image prune -f
That last line matters: old images fill the disk silently. What happens when the disk fills up is covered in troubleshooting.
Teach Morpheus this as a habit
"from now on when I say 'update the app', run git pull, docker compose pull, docker compose up -d in /opt/myapp and clean up unused images"
Checklist#
- [ ]
docker compose ps→ all servicesUp - [ ]
sudo ss -tlnp→ app ports bound to127.0.0.1only - [ ] Every service has
restart: unless-stopped - [ ]
sudo systemctl is-enabled docker→enabled - [ ] The site comes back on its own after a reboot
- [ ]
https://example.comopens with the padlock - [ ]
docker system dfshows a reasonable amount of accumulated junk