Skip to content

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 services Up
  • [ ] sudo ss -tlnp → app ports bound to 127.0.0.1 only
  • [ ] Every service has restart: unless-stopped
  • [ ] sudo systemctl is-enabled dockerenabled
  • [ ] The site comes back on its own after a reboot
  • [ ] https://example.com opens with the padlock
  • [ ] docker system df shows a reasonable amount of accumulated junk