Skip to content

Deploy a Docker Compose app from GitHub

Your compose.yaml on a VM of its own. git push to deploy.

Deploy on exe.dev

Shelley handles the setup. About 10 minutes.

Prefer a terminal?
curl -fsSL https://exe.dev/deploy/docker-compose/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

Got a repository with a compose.yaml? Then you have an app that runs anywhere Docker does, in any language, with its database, cache and workers beside it. This page puts it on an exe.dev VM of its own: git push builds and deploys it, exe.dev serves it over HTTPS, its data sits on a persistent disk, and it comes back after a reboot.

Unlike a container platform, it's a real computer: volumes and SQLite just work, nothing sleeps between requests, you have SSH and an agent on the box, and it stays private until you share it.

What you get#

  • Your Compose app, built from your repo (or a fresh web app with PostgreSQL), served on port 8000.
  • git push to deploy; a push that fails to build or start is rejected, and the running version stays up.
  • Docker enabled at boot and a restart policy on every long-running service, so the app survives a reboot.
  • Nightly backups (database dumps, ~/data and settings), with a restore tested.
What's running, and where
Private https://my-docker-compose.exe.xyz/ The service you publish on port 8000; public only if you choose
Private https://my-docker-compose.exe.xyz:8081/ Any other service you publish on a port from 3000 to 9999
Private ssh my-docker-compose.exe.xyz git push to deploy; shell and logs
my-docker-compose.exe.xyz exe.dev VM
Your Compose app Built from your repo on every git push; any language :8000~/data
Its other services Databases, caches and workers from compose.yaml, on private ports
Deploy hook Builds, starts, and prunes old images on git push ~/app.git
Nightly backup Database dumps, ~/data and settings; keeps 14 ~/backups

Deploy with Shelley#

Click Deploy and Shelley will set up Docker Compose on a new VM, then check that it works. No config-file wrangling required.

Have these ready; Shelley asks for them:

  • REPO: the GitHub repository to deploy, as owner/name or a URL, or new to start a fresh Compose app (a small web app and PostgreSQL)
Prefer a terminal?
curl -fsSL \
  https://exe.dev/deploy/docker-compose/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin

When it's done, run this on your computer (only you can):

git clone -o exe my-docker-compose.exe.xyz:app.git my-app   # if you started fresh
git remote add exe my-docker-compose.exe.xyz:app.git        # or, in your own repo's checkout
What Shelley is told
REPO=<fill-this-in>

Set up Docker Compose on this VM by following the exe.dev recipe at https://exe.dev/deploy/docker-compose.md from top to bottom.

The values at the top of this message are mine:
- REPO: the GitHub repository to deploy, as owner/name or a URL, or `new` to start a fresh Compose app (a small web app and PostgreSQL)
Ask me for any that still say <fill-this-in> before you need them.

The recipe's example VM name is my-docker-compose. This VM's name is the output of `hostname`: use it wherever the recipe writes my-docker-compose. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- Your Compose app, built from your repo (or a fresh web app with PostgreSQL), served on port 8000.
- git push to deploy; a push that fails to build or start is rejected, and the running version stays up.
- Docker enabled at boot and a restart policy on every long-running service, so the app survives a reboot.
- Nightly backups (database dumps, ~/data and settings), with a restore tested.

Read the whole recipe, including the closed details, and follow
"Install by hand" in order, skipping VM creation: set up Docker, make
the deploy repository, then "Start fresh" if REPO is new or "Bring your
repo" otherwise, then backups. The walkthrough above it is for the
owner afterwards. Don't run the later Restore or Update steps, don't
reboot, and leave sharing private.
With a repo, never change it on GitHub: VM-only changes go in
~/app.override.yaml and settings in ~/app.env. Generate passwords and
keys; ask the owner for values only they have, like third-party API
keys. If the clone fails, give the owner the GitHub integration command
from "Bring your repo" with this VM's name and the repo, and wait.
Write app-backup.sh for the databases the stack really runs, and test
a restore into a scratch database. To check a failing push, push a
commit that breaks the build from a scratch clone: it must be
rejected. Remove your scratch clones and test scripts when done.
Finish with https://<vm>.exe.xyz/, any other ports the app publishes,
and only the one git command from "Deploy with git push" that fits:
`git clone -o exe` for REPO=new, `git remote add exe` for a repository.

Finish with the URLs I should open and these commands, which only I can run, from my own computer (with this VM's name in them):
  git clone -o exe my-docker-compose.exe.xyz:app.git my-app   # if you started fresh
  git remote add exe my-docker-compose.exe.xyz:app.git        # or, in your own repo's checkout

Also at /deploy/docker-compose/prompt.txt. The recipe it follows is /deploy/docker-compose.md.

Open your app#

When Shelley is done, open https://my-docker-compose.exe.xyz/ and sign in to exe.dev. You'll see the service your compose file publishes on port 8000. Started fresh? It greets you by your exe.dev email and counts your visits in PostgreSQL. Only you, and people you share the VM with, can open it.

On the VM, everything about the app is in a few places:

What Where
The repository you push to ~/app.git
The running code: the files of the commit that's running ~/app
Settings and secrets (mode 600) ~/app.env, linked as ~/app/.env
This VM's changes to your compose file, if any ~/app.override.yaml
Data: bind mounts, SQLite files, uploads ~/data
Backups ~/backups
Logs docker compose logs, run in ~/app

Deploy with git push#

On your computer, in your repository, add the VM as a remote and push:

git remote add exe my-docker-compose.exe.xyz:app.git
git push exe main

Started fresh? Your code is on the VM already. Clone it instead, and the remote is set up for you:

git clone -o exe my-docker-compose.exe.xyz:app.git my-app

The deploy runs inside git push, and you watch it happen:

remote: -----> Building 4f2c9e1 Add a guestbook
remote:  web Built
remote: -----> Starting
remote:  Container app-db-1  Running
remote:  Container app-web-1  Recreated
remote:  Container app-web-1  Healthy
remote: -----> Deployed 4f2c9e1 to https://my-docker-compose.exe.xyz/

The VM puts your commit's files in ~/app, builds its images and pulls any it's missing, all while the old version keeps serving. Then docker compose up --wait replaces only the containers that changed and waits for their healthchecks; your database keeps running. Expect a second or two of 502s while the web container swaps over, plus however long your app takes to boot, so keep startup quick. Finally, old images are pruned so they don't fill the disk.

Keep pushing to GitHub as usual; it stays the source of truth, and exe is where you deploy, the way heroku used to be. To go back to an earlier version, push it: git push -f exe 1a2b3c4:main.

When a deploy fails#

As on Heroku, a deploy that fails rejects the push, and the VM keeps running the version it had. If the build fails, nothing else changes:

remote: failed to solve: process "/bin/sh -c pip install --no-cache-dir -r requirements.txt" did not complete successfully: exit code: 1
remote: -----> Build failed. Push rejected; nothing changed.
To my-docker-compose.exe.xyz:app.git
 ! [remote rejected] main -> main (pre-receive hook declined)

If the new version builds but crashes or fails its healthcheck, the push shows the containers' state and their recent logs, then puts the previous version back, from Docker's cache, in seconds:

remote: web-1  | KeyError: 'SECRET_KEY'
remote: -----> Rolling back to fd6a837
remote: -----> 3070bd0 didn't start. Push rejected.

Either way, main on the VM stays at the commit that's running. Fix it and push again. A rollback can't undo database migrations the new version already ran, so make them backward-compatible, or restore a backup.

To look around on the VM:

cd ~/app
docker compose ps              # what's running, and its health
docker compose logs -f web     # follow one service's logs
docker compose exec db psql -U app   # a shell in a service

Or open Shelley at https://my-docker-compose.shelley.exe.xyz/ and ask: "the site shows a 502, find out why." It can read the logs, fix the code, and deploy the fix.

Port 8000, and the other ports#

exe.dev serves port 8000 at https://my-docker-compose.exe.xyz/. Pick the service people should visit, your web app or the proxy in front of it, and publish it there:

services:
  web:
    ports:
      - "127.0.0.1:8000:3000"   # host 8000 -> the container's port 3000

127.0.0.1 keeps the port off the VM's network; exe.dev's HTTPS proxy still reaches it. Publish anything else people open in a browser, such as an admin UI, on a port from 3000 to 9999, and it's at https://my-docker-compose.exe.xyz:8081/, for signed-in users only. Don't publish databases at all: other services reach them by service name (db:5432) on Compose's network.

exe.dev ends TLS and sends your app plain HTTP with X-Forwarded-Proto: https and X-Forwarded-Host. Frameworks that don't trust those headers build http:// links, loop on redirects to HTTPS, or reject forms with CSRF errors. Turn on your framework's proxy setting, and pass the headers through if you have nginx or Caddy in front.

Change things for this VM only#

If your compose file publishes a different port, or is missing restart policies, you don't have to change your repository. Put this VM's changes in ~/app.override.yaml, for example:

services:
  web:
    ports: !override
      - "127.0.0.1:8000:80"
    restart: unless-stopped

Then list both files in ~/app.env, your compose file first:

COMPOSE_FILE=compose.yaml:/home/exedev/app.override.yaml

!override replaces the repo's list of ports instead of adding to it. Apply it with cd ~/app && docker compose up -d --wait. The same COMPOSE_FILE setting picks a compose file in a subdirectory, or a production file such as compose.yaml:compose.prod.yaml.

Settings and secrets#

Put settings in ~/app.env, one KEY=value per line. It lives outside the checkout, so deploys never touch it, and every deploy links it into ~/app as .env, where Compose reads it. So your compose file can use ${POSTGRES_PASSWORD}, or pass the whole file to a service with env_file: .env. Add .env to your .dockerignore, so it isn't copied into images.

After editing it, apply the change; this recreates only the services whose settings changed:

cd ~/app && docker compose up -d --wait

docker compose restart doesn't reread settings. Keep a copy of ~/app.env in your password manager; the nightly backup has one too.

Your data#

The VM's disk is persistent: containers come and go on every deploy, but volumes stay. You have two kinds:

  • Bind mounts under ~/data, such as ${DATA_DIR}/postgres:/var/lib/postgresql with DATA_DIR=/home/exedev/data in ~/app.env. Plain files you can see, copy and back up. Create the directory before the first deploy if the container doesn't run as root, or Docker creates it owned by root.
  • Named volumes (db-data:/var/lib/postgresql/data), managed by Docker in /var/lib/docker/volumes. Fine too, but docker compose down -v deletes them, so never add -v here.

A database's files change while it runs, so a copy of them isn't a backup. The nightly backup dumps each database with its own tool and copies ~/data alongside.

Would you rather run PostgreSQL on the VM itself, outside Compose, with its own tuning, backups and upgrades? Set it up with PostgreSQL, and let your containers reach it as shown there.

Backups#

Every night, ~/app-backup.sh writes ~/backups/app-<date>.tar.gz with a dump of each database, the files in ~/data, ~/app.env and the commit that was running. It keeps the newest 14. A backup on the VM won't save you if you lose the VM, so copy them off now and then. On your computer:

scp -r my-docker-compose.exe.xyz:backups .

They hold your secrets; keep them private. To take one now, run ~/app-backup.sh on the VM. To restore, see Back up and restore.

Build on the VM, or pull images#

By default the VM builds every service that has a build: section, on each push, with Docker's layer cache. That needs no registry and suits most apps; a 2-vCPU VM builds a typical Node or Python app in a minute or two. The build runs in Docker, so the VM needs no Node.js, Python or other toolchain of its own: your Dockerfile brings it.

If CI already builds your images, reference them with image: and let the VM pull them. Public images need nothing more. A push that changes a tag, say ghcr.io/you/app:1.4.2, deploys it. For a tag that moves, like :main, add pull_policy: always to the service, so every deploy fetches the latest.

Private images:

  • ghcr.io, quay.io, Google Artifact Registry: connect the registry once with an exe.dev registry integration, attached to this VM. The VM then pulls through the integration's hostname, with no password on the VM: an integration named ghcr serves ghcr.io/you/app as ghcr.int.exe.xyz/you/app. Point image: there, in your compose file or in ~/app.override.yaml.
  • Anywhere else, including Docker Hub: on the VM, run docker login <registry> with a read-only token. Docker keeps it in ~/.docker/config.json.

Keep the disk from filling up#

A new VM has 25 GB, and the system uses about 5. Each deploy prunes images no longer in use and trims the build cache to 3 GB, and containers log to Docker's local driver, which rotates logs at 100 MB per container. To see where space goes:

df -h /
docker system df

To free more, docker image prune -a removes every image that no container uses; the next build pulls base images again. docker volume prune deletes volumes, so read its list before you say yes.

Make it public, add a domain#

The VM is private until you open it. On your computer:

ssh exe.dev share set-public my-docker-compose

That makes port 8000 public; other ports stay private. ssh exe.dev share set-private my-docker-compose closes it again. To serve it at your own domain, point a CNAME at my-docker-compose.exe.xyz and run ssh exe.dev domain add my-docker-compose app.example.com (custom domains). Add the domain wherever your app lists its allowed hosts.

While it's private, exe.dev has already signed everyone in: each request carries the visitor's X-ExeDev-Email. An internal tool can use that instead of its own login (Login with exe.dev). Once it's public, visitors who haven't signed in get no header; send them to https://my-docker-compose.exe.xyz/__exe.dev/login?redirect=/ to sign in, as the fresh app's "Sign in" link does.

The nuts and bolts#

Happy clicking Deploy? You can stop here. These are the steps Shelley follows, and the commands for later.

Install by hand

Create the VM#

On your computer (pick another name if my-docker-compose is taken):

ssh exe.dev new --name=my-docker-compose
ssh my-docker-compose.exe.xyz

Everything else runs on the VM, as exedev.

Set up Docker#

exe.dev starts Docker on demand. Enable it at boot, so your containers come back after a reboot, and switch container logs to Docker's local driver, which rotates them:

sudo systemctl enable --now docker.service
if [ ! -e /etc/docker/daemon.json ]; then
  echo '{"log-driver": "local"}' | sudo tee /etc/docker/daemon.json
  sudo systemctl restart docker.service
fi
systemctl is-enabled docker.service
docker info --format '{{.LoggingDriver}}'

Expect enabled and local.

Make the deploy repository#

Create the repository you'll push to, the settings file, and the directories for data and backups:

mkdir -p ~/data ~/backups
touch ~/app.env && chmod 600 ~/app.env
grep -q '^COMPOSE_PROJECT_NAME=' ~/app.env || echo 'COMPOSE_PROJECT_NAME=app' >> ~/app.env
git init -q --bare -b main ~/app.git

COMPOSE_PROJECT_NAME names the Compose project app whatever your compose file says, so containers are app-web-1 and named volumes app_<name>, the same on every deploy.

Then the hook that deploys each push. It runs before git accepts the push, so a deploy that fails rejects it:

cat > ~/app.git/hooks/pre-receive <<'EOF'
#!/usr/bin/env bash
# git push deploys: build the pushed commit in ~/app and start it with
# Docker Compose, before git accepts the push. If the build or the start
# fails, put back what was running and reject the push, so main in
# ~/app.git is always the commit that's running.
set -euo pipefail
zero=0000000000000000000000000000000000000000
branch=$(git symbolic-ref --short HEAD)
rev=
while read -r old new ref; do
  if [ "$ref" != "refs/heads/$branch" ]; then echo "Not deploying $ref: only $branch deploys."; continue; fi
  if [ "$new" = "$zero" ]; then echo "-----> $branch is what's running; it can't be deleted."; exit 1; fi
  prev=$old rev=$new
done
[ -n "$rev" ] || exit 0

exec 9>"$HOME/.deploy.lock" && flock 9    # one deploy at a time
# Put a commit's files in ~/app (without touching refs: the push isn't accepted yet).
checkout() {
  mkdir -p "$HOME/app"
  GIT_WORK_TREE=$HOME/app GIT_INDEX_FILE=$HOME/.deploy-index git --git-dir="$HOME/app.git" read-tree -u --reset "$1"
  ln -sfn "$HOME/app.env" "$HOME/app/.env"    # settings live outside the code
}
echo "-----> Building $(git log -1 --format='%h %s' "$rev")"
checkout "$rev"
cd "$HOME/app" && unset GIT_DIR
# Without build attestations, unchanged code builds the same image ID,
# so Compose leaves services that didn't change running.
export BUILDX_NO_DEFAULT_ATTESTATIONS=1
started=$(date +%s)
if ! { docker compose build && docker compose pull -q --ignore-buildable --policy missing; }; then
  if [ "$prev" != "$zero" ]; then checkout "$prev"; fi
  echo "-----> Build failed. Push rejected; nothing changed."
  exit 1
fi

echo "-----> Starting"
up() { docker compose up -d --remove-orphans --quiet-pull --wait --wait-timeout 180 "$@"; }
if ! up; then
  docker compose ps -a
  docker compose logs --since "$started" --tail=30
  if [ "$prev" = "$zero" ]; then
    docker compose down
  else
    echo "-----> Rolling back to ${prev:0:7}"
    checkout "$prev"
    up --build || echo "-----> The rollback failed too. See: cd ~/app && docker compose logs"
  fi
  echo "-----> ${rev:0:7} didn't start. Push rejected."
  exit 1
fi

docker image prune -f >/dev/null                      # old images
docker builder prune -f --max-used-space 3gb >/dev/null  # build cache
docker compose ps
norestart=$(docker compose config --format json |
  jq -r '.services | to_entries[] | select((.value.restart // "no") == "no") | .key')
if [ -n "$norestart" ]; then
  echo "-----> No restart policy, so these won't come back after a reboot:" $norestart
  echo "       Fine for one-off jobs; give the others restart: unless-stopped."
fi
echo "-----> Deployed ${rev:0:7} to https://$(hostname).exe.xyz/"
EOF
chmod +x ~/app.git/hooks/pre-receive

Now follow one of the next two steps: start a new app, or bring yours.

Start fresh#

This is a tiny but complete Compose app: a Python web app, built from its Dockerfile, and PostgreSQL, with healthchecks, restart policies, the database in ~/data, and its password in ~/app.env. Replace the app with yours later; keep the shape.

mkdir -p /tmp/new-app && cd /tmp/new-app

cat > compose.yaml <<'EOF'
# A web app built from this repo, and PostgreSQL.
# Settings come from .env (on exe.dev, a link to ~/app.env).
services:
  web:
    build: .
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:8000"   # exe.dev serves port 8000 at https://<vm>.exe.xyz/
    environment:
      DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/app
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"]
      interval: 30s
      timeout: 5s
      start_period: 30s
      start_interval: 1s

  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - ${DATA_DIR:-./data}/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 10s
      timeout: 5s
      start_period: 30s
      start_interval: 1s
EOF

cat > Dockerfile <<'EOF'
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home app
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind=0.0.0.0:8000", "--workers=2", "--preload", "--access-logfile=-", "app:app"]
EOF

cat > requirements.txt <<'EOF'
flask~=3.1
gunicorn~=26.2
psycopg[binary]~=3.3
EOF

cat > app.py <<'EOF'
"""A tiny web app that counts visits in PostgreSQL. Replace it with yours."""
import os

import psycopg
from flask import Flask, request
from markupsafe import escape

app = Flask(__name__)
DATABASE_URL = os.environ["DATABASE_URL"]

with psycopg.connect(DATABASE_URL) as conn:
    conn.execute("CREATE TABLE IF NOT EXISTS visits "
                 "(id serial PRIMARY KEY, at timestamptz NOT NULL DEFAULT now(), email text)")


@app.get("/")
def index():
    # exe.dev's proxy sends X-ExeDev-Email for visitors signed in to exe.dev.
    email = request.headers.get("X-ExeDev-Email")
    with psycopg.connect(DATABASE_URL) as conn:
        conn.execute("INSERT INTO visits (email) VALUES (%s)", (email,))
        (count,) = conn.execute("SELECT count(*) FROM visits").fetchone()
    # Once the VM is public, visitors sign in at /__exe.dev/login.
    sign_in = "" if email else '<p><a href="/__exe.dev/login?redirect=/">Sign in</a></p>\n'
    return f"<h1>Hello, {escape(email or 'stranger')}!</h1>\n<p>Visit number {count}, counted in PostgreSQL.</p>\n{sign_in}"


@app.get("/healthz")
def healthz():
    with psycopg.connect(DATABASE_URL) as conn:
        conn.execute("SELECT 1")
    return "ok\n"
EOF

cat > .env.example <<'EOF'
# Copy to .env and fill in. On exe.dev, these live in ~/app.env.
POSTGRES_PASSWORD=change-me
DATA_DIR=./data
EOF
printf '.env\ndata/\n__pycache__/\n' > .gitignore
printf '.env\ndata/\n.git\n__pycache__/\n' > .dockerignore

Generate the database password, once: PostgreSQL keeps the one it started with.

grep -q '^POSTGRES_PASSWORD=' ~/app.env || cat >> ~/app.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
DATA_DIR=$HOME/data
EOF

Commit it and push it to the deploy repository. This first push deploys, exactly as your own pushes will:

cd /tmp/new-app
git init -q -b main && git add -A
git -c user.name="$(hostname)" -c user.email="exedev@$(hostname).exe.xyz" commit -qm "New Compose app: web and PostgreSQL"
git push -q ~/app.git main
curl -s localhost:8000/

The push ends with -----> Deployed, and the page says Hello, stranger! with a visit number: through exe.dev it will use your email. Then remove the temporary copy; the code lives in ~/app.git:

cd ~ && rm -rf /tmp/new-app

Skip to Set up backups.

Bring your repo#

Set REPO to the repository, as owner/name, and clone it. Public repositories clone from GitHub; private ones through exe.dev's GitHub integration, with no token on the VM:

REPO=owner/name
repo=${REPO#https://github.com/}; repo=${repo%.git}
rm -rf /tmp/seed
export GIT_TERMINAL_PROMPT=0
git clone -q "https://github.com/$repo.git" /tmp/seed ||
  git clone -q "https://github.int.exe.xyz/$repo.git" /tmp/seed

If both fail, the repository is private and the VM can't reach it yet. On your computer, link GitHub on exe.dev's Integrations page if you haven't, then run this with your repository, and try the clone again:

ssh exe.dev integrations add github --name app-repo --repository owner/name --attach vm:my-docker-compose

Deploy the repository's default branch:

branch=$(git -C /tmp/seed symbolic-ref --short HEAD)
git -C ~/app.git symbolic-ref HEAD "refs/heads/$branch"
echo "$branch"

Now fit it to the VM. Look at what the compose file asks for:

cd /tmp/seed
ls -a
ln -sfn ~/app.env .env
docker compose config --format json | jq '.services | map_values({ports, restart, volumes: [.volumes[]? | .source]})'

Work through this list, then run docker compose config --quiet until it prints nothing:

  1. Compose file. Not at the top of the repo, or more than one? Add COMPOSE_FILE= to ~/app.env, as in Change things for this VM only.
  2. Settings. Copy each variable the app needs from its .env.example (or README) into ~/app.env. Generate passwords and keys with openssl rand -hex 32. If the repo commits a .env, copy its values too (git show HEAD:.env): deploys replace it with a link to ~/app.env.
  3. Port 8000. If no service publishes host port 8000, add one in ~/app.override.yaml: the service people should visit, as 127.0.0.1:8000:<its port>. Move other web ports to 3000-9999 on 127.0.0.1, and drop published database ports.
  4. Restart policies and healthchecks. Give every long-running service restart: unless-stopped, in the override if the repo lacks it. Leave one-off jobs, like migrations, without one. Deploys wait for healthchecks, and a service without one counts as up as soon as it starts, even if it crashes a second later. So give the web service a healthcheck: that fetches a page, as in the fresh app's compose.yaml, with a tool its image has (curl, wget, python or node).
  5. Development settings. Many compose files are written for a laptop: they mount the source into the container (./src:/app), run a dev server with reloading, or build a dev target. Override those for the app's services: volumes: !reset [] (only where the volumes are source code), and the production target: (look for stages in the Dockerfile), command: or entrypoint:. Switch off dev-only services, like a database admin UI, or a proxy that only routes localhost between dev servers, with profiles: [dev]: Compose skips services whose profile isn't enabled. Replace hard-coded passwords, like MYSQL_ROOT_PASSWORD: secret, with ${...} from ~/app.env. A compose.prod.yaml in the repo may already do all this.
  6. Data. Note each database service and each named volume or ~/data directory with files worth keeping, for the backup script.

For example, Docker's example voting app publishes ports 8080 and 8081, has no restart policies, and runs its two web apps in development mode. Its ~/app.override.yaml:

# This VM's changes to the repo's compose file. See COMPOSE_FILE in ~/app.env.
services:
  vote:
    build:
      target: final            # gunicorn, not Flask's dev server
    volumes: !reset []         # run the built code, not the checkout
    ports: !override
      - "127.0.0.1:8000:80"
    restart: unless-stopped
  result:
    entrypoint: !reset null    # the image's node, not nodemon
    volumes: !reset []
    ports: !override
      - "127.0.0.1:8081:80"
    restart: unless-stopped
  worker:
    restart: unless-stopped
  redis:
    restart: unless-stopped
  db:
    restart: unless-stopped

and ~/app.env holds COMPOSE_FILE=docker-compose.yml:/home/exedev/app.override.yaml.

Push it to the deploy repository; this first push deploys it:

cd /tmp/seed
git push -q ~/app.git "HEAD:$(git symbolic-ref --short HEAD)"
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: $(hostname).exe.xyz" localhost:8000/

The push ends with -----> Deployed, and the status is 200 (or a redirect, 302, for an app that starts at a login page). The Host header is the one visitors' requests carry, so this also catches apps and proxies that only answer to localhost. If the push is rejected, see Something not working?, fix ~/app.env or the override, and push again. If it deployed but the status is wrong, apply your fix with cd ~/app && docker compose up -d --wait. When it works, remove the temporary clone:

cd ~ && rm -rf /tmp/seed

Set up backups#

The backup script dumps each database, then archives the dumps with ~/data and the settings. This one is for the fresh app's PostgreSQL; for your own stack, write one dump line per database service (see Other databases) and exclude their directories in ~/data from the tar:

cat > ~/app-backup.sh <<'EOF'
#!/usr/bin/env bash
# Back up the app to ~/backups/app-<time>.tar.gz: a dump of each
# database, the files in ~/data, and the settings. Keeps the newest 14.
set -euo pipefail
umask 077
exec 9>~/.deploy.lock && flock 9   # wait for any deploy to finish
cd ~/app
work=$(mktemp -d ~/backups/.work-XXXXXX)
trap 'rm -rf "$work"' EXIT

# Databases: dump them; a copy of a running database's files isn't
# consistent. One line per database service.
docker compose exec -T db pg_dump -U app -d app --format=custom > "$work/db.dump"

# Files in ~/data, except the databases' own (dumped above).
sudo tar -C ~ --exclude=data/postgres -cf - data > "$work/data.tar"

# Settings, and the commit that was running.
cp ~/app.env "$work/"
[ ! -e ~/app.override.yaml ] || cp ~/app.override.yaml "$work/"
git -C ~/app.git rev-parse HEAD > "$work/commit"

out=~/backups/app-$(date -u +%Y%m%d-%H%M%S).tar.gz
tar -C "$work" -czf "$out.part" .
mv "$out.part" "$out"
ls -1t ~/backups/app-*.tar.gz | tail -n +15 | xargs -r rm --
echo "$out"
EOF
chmod +x ~/app-backup.sh

Run it every night with a systemd timer, and take the first backup now:

sudo tee /etc/systemd/system/app-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up the Compose app to ~/backups
After=docker.service

[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/app-backup.sh
EOF
sudo tee /etc/systemd/system/app-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the Compose app every night

[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer
sudo systemctl start app-backup.service
systemctl list-timers app-backup.timer
ls -lh ~/backups

The timer shows its next run, and ~/backups has one archive. If not: journalctl -u app-backup.service.

Test a restore#

Restore the newest dump into a scratch database next to the real one, and count what came back:

latest=$(ls -1t ~/backups/app-*.tar.gz | head -n 1)
rm -rf /tmp/restore-test && mkdir /tmp/restore-test
tar -xzf "$latest" -C /tmp/restore-test
cd ~/app
docker compose exec -T db dropdb -U app --if-exists restore_test
docker compose exec -T db createdb -U app restore_test
docker compose exec -T db pg_restore -U app -d restore_test --no-owner --exit-on-error < /tmp/restore-test/db.dump
docker compose exec -T db psql -U app -d restore_test -c 'SELECT count(*) FROM visits;'
docker compose exec -T db dropdb -U app restore_test
rm -rf /tmp/restore-test

Expect a count of at least 1. For your own stack, use your database service, user and one of your tables. Installation done: go back to Open your app.

Back up and restore

To take a backup now, on the VM: ~/app-backup.sh. It prints the archive's path. Each archive holds db.dump, data.tar, app.env, app.override.yaml if you have one, and commit.

Restore on the same VM#

This replaces the database and the files in ~/data with the backup's. Pick the archive and use its name; these commands are for the fresh app's PostgreSQL:

backup=~/backups/app-20261017-030512.tar.gz
rm -rf /tmp/restore && mkdir /tmp/restore
tar -xzf "$backup" -C /tmp/restore
cd ~/app
docker compose stop web
docker compose exec -T db dropdb -U app --force app
docker compose exec -T db createdb -U app app
docker compose exec -T db pg_restore -U app -d app --no-owner --exit-on-error < /tmp/restore/db.dump
sudo tar -C ~ -xf /tmp/restore/data.tar
docker compose start web
rm -rf /tmp/restore

Open the app and check your data is back.

Restore on a new VM#

On the new VM, follow Install by hand up to and including Make the deploy repository. Copy the backup over from your computer, and put its settings in place before the first deploy, so the database starts with the same password:

scp app-20261017-030512.tar.gz my-docker-compose.exe.xyz:backups/

On the VM:

mkdir -p /tmp/restore
tar -xzf ~/backups/app-20261017-030512.tar.gz -C /tmp/restore
install -m 600 /tmp/restore/app.env ~/app.env
[ ! -e /tmp/restore/app.override.yaml ] || cp /tmp/restore/app.override.yaml ~/
cat /tmp/restore/commit

If the VM has a new name, change it wherever ~/app.env mentions the old one. Then deploy your code with a push from your computer, or by cloning it on the VM as in Bring your repo, and run the restore above. Set up backups again, as in Set up backups.

Other databases#

One line per database service in ~/app-backup.sh, with the service's name in place of db. These read the official images' own settings:

# PostgreSQL
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "${POSTGRES_DB:-$POSTGRES_USER}" -Fc' > "$work/db.dump"
# MySQL
docker compose exec -T db sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --all-databases' > "$work/db.sql"
# MariaDB
docker compose exec -T db sh -c 'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction --all-databases' > "$work/db.sql"
# MongoDB
docker compose exec -T db sh -c 'exec mongodump --archive --gzip -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin' > "$work/db.archive"
# SQLite, in a file under ~/data
sqlite3 ~/data/app.db ".backup '$work/app.db'"

Restore them with pg_restore, mysql or mariadb reading the .sql file, mongorestore --archive --gzip --drop, or by copying the SQLite file back while the app is stopped. Files in a named volume rather than ~/data: stream them out with docker run --rm -v app_uploads:/v:ro alpine tar -C /v -cf - . > "$work/uploads.tar" (the volume is named app_ and the name in your compose file). A cache like Redis usually needs no backup.

Update

Your app updates with every git push. The rest, on the VM:

  • Images you pull, like postgres:18: docker compose pull fetches the newest image for each tag, and docker compose up -d --wait starts them. Back up first. A new major version of a database, like PostgreSQL 18 to 19, can't read the old one's files: dump, upgrade, restore.
  • Base images in your Dockerfiles: deploys reuse the cached ones. For security fixes, rebuild with fresh ones now and then: cd ~/app && docker compose build --pull && docker compose up -d --wait.
  • Docker comes from Ubuntu's packages (docker.io, docker-compose-v2): sudo apt-get update && sudo apt-get upgrade. Upgrading restarts Docker and your containers with it.
Something not working?

Start on the VM:

cd ~/app
docker compose ps -a
docker compose logs --tail=100
  • 502 or an error page at https://my-docker-compose.exe.xyz/: nothing answers on port 8000. Check curl -sI localhost:8000/ and docker compose ps: the web service must show 127.0.0.1:8000->.
  • required variable POSTGRES_PASSWORD is missing (or any other): add it to ~/app.env, then docker compose up -d --wait.
  • port is already allocated: two services, or another program, want the same host port. sudo ss -ltnp shows who has it.
  • Redirect loops, http:// links, CSRF errors: your app doesn't trust exe.dev's X-Forwarded-Proto: https. See Port 8000, and the other ports.
  • Traefik or Caddy can't get a certificate: they can't, here. exe.dev handles TLS; serve plain HTTP on port 8000.
  • 404 at the real URL, but curl localhost:8000 works: a proxy's routing rules, or the app's allowed hosts, only match localhost. Add my-docker-compose.exe.xyz and test with curl -H "Host: my-docker-compose.exe.xyz" localhost:8000/.
  • Permission denied writing to ~/data/...: Docker created the directory as root. Give it to the container's user, for example sudo chown -R 1000:1000 ~/data/uploads.
  • no space left on device: see Keep the disk from filling up.
  • The push is rejected (pre-receive hook declined): the deploy failed and the old version is still running. The remote: lines above it show the build error, or the logs of the service that didn't start.
  • The push says Permission denied (publickey): add your SSH key to exe.dev, or use the remote vm+my-docker-compose@vm.exe.xyz:app.git (SSH destinations).
  • Down after a reboot: run sudo systemctl enable --now docker.service, and check every service has a restart policy; the last deploy's output names any that don't.
  • A service you changed in ~/app.env didn't change: use docker compose up -d, not restart.

Frequently asked questions#

Can I deploy any docker-compose.yml?

Almost. Anything that runs with docker compose up on a Linux server runs here: any language, any image, as many services as fit in the VM. What exe.dev doesn't offer is raw TCP or UDP from the internet; visitors reach your services over HTTPS, on port 8000 or a port from 3000 to 9999.

Do I need my own reverse proxy, like Traefik, Caddy or nginx?

No. exe.dev's HTTPS proxy handles certificates and TLS, and sends your app plain HTTP with X-Forwarded-* headers. If your stack has a proxy for routing, serve it on port 8000 without TLS; a proxy that tries to get Let's Encrypt certificates will fail.

Should I use named volumes or bind mounts?

Either is on the VM's persistent disk. Bind mounts under ~/data are plain files you can see and copy; named volumes are managed by Docker and deleted by docker compose down -v. Back up databases with a dump, whichever you use.

Is there downtime when I deploy?

A second or two. The new version is built while the old one keeps serving; then Compose replaces only the containers that changed, so your database keeps running.

Can I deploy from a private repository or a private registry?

Yes. The GitHub integration clones private repos with no token on the VM, and exe.dev's registry integrations pull private images from ghcr.io, quay.io and Google Artifact Registry.

How big can my app be?

A new VM has a 25 GB disk and shares your plan's CPU and memory with your other VMs. The smallest plan's 2 vCPUs and 4 GB run a typical web app, its database and a few workers. For more, see exe.dev pricing.

Updated Oct 17, 2026 · tested end to end Oct 17, 2026 · Markdown · manifest.json · Something wrong? Tell us