---
title: "Deploy a Docker Compose app from GitHub"
description: "Deploy any Docker Compose app from GitHub to an exe.dev VM: git push to build and deploy, HTTPS, volumes on a persistent disk, nightly database dumps."
url: https://exe-staging.dev/deploy/docker-compose
category: "Frameworks"
updated: 2026-10-17
---

# Deploy a Docker Compose app from GitHub

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

- **Software:** Docker Compose (https://docs.docker.com/compose/)
- **Source:** https://github.com/docker/compose
- **License:** Apache-2.0
- **Time:** 10 minutes
- **Example VM name:** `my-docker-compose` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe-staging.dev/deploy/docker-compose/manifest.json

## 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.

Running on the VM:

- **Your Compose app**: Built from your repo on every git push; any language, port 8000, data in `~/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, data in `~/app.git`
- **Nightly backup**: Database dumps, ~/data and settings; keeps 14, data in `~/backups`

How it is reached:

- https://my-docker-compose.exe.xyz/ (private): The service you publish on port 8000; public only if you choose
- https://my-docker-compose.exe.xyz:8081/ (private): Any other service you publish on a port from 3000 to 9999
- ssh my-docker-compose.exe.xyz (private): git push to deploy; shell and logs

## Deploy with Shelley

Open https://exe-staging.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Docker%20Compose%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe-staging.dev%2Fdeploy%2Fdocker-compose.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20REPO%3A%20the%20GitHub%20repository%20to%20deploy%2C%20as%20owner%2Fname%20or%20a%20URL%2C%20or%20%60new%60%20to%20start%20a%20fresh%20Compose%20app%20%28a%20small%20web%20app%20and%20PostgreSQL%29%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-docker-compose.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-docker-compose.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Your%20Compose%20app%2C%20built%20from%20your%20repo%20%28or%20a%20fresh%20web%20app%20with%20PostgreSQL%29%2C%20served%20on%20port%208000.%0A-%20git%20push%20to%20deploy%3B%20a%20push%20that%20fails%20to%20build%20or%20start%20is%20rejected%2C%20and%20the%20running%20version%20stays%20up.%0A-%20Docker%20enabled%20at%20boot%20and%20a%20restart%20policy%20on%20every%20long-running%20service%2C%20so%20the%20app%20survives%20a%20reboot.%0A-%20Nightly%20backups%20%28database%20dumps%2C%20~%2Fdata%20and%20settings%29%2C%20with%20a%20restore%20tested.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details%2C%20and%20follow%0A%22Install%20by%20hand%22%20in%20order%2C%20skipping%20VM%20creation%3A%20set%20up%20Docker%2C%20make%0Athe%20deploy%20repository%2C%20then%20%22Start%20fresh%22%20if%20REPO%20is%20new%20or%20%22Bring%20your%0Arepo%22%20otherwise%2C%20then%20backups.%20The%20walkthrough%20above%20it%20is%20for%20the%0Aowner%20afterwards.%20Don%27t%20run%20the%20later%20Restore%20or%20Update%20steps%2C%20don%27t%0Areboot%2C%20and%20leave%20sharing%20private.%0AWith%20a%20repo%2C%20never%20change%20it%20on%20GitHub%3A%20VM-only%20changes%20go%20in%0A~%2Fapp.override.yaml%20and%20settings%20in%20~%2Fapp.env.%20Generate%20passwords%20and%0Akeys%3B%20ask%20the%20owner%20for%20values%20only%20they%20have%2C%20like%20third-party%20API%0Akeys.%20If%20the%20clone%20fails%2C%20give%20the%20owner%20the%20GitHub%20integration%20command%0Afrom%20%22Bring%20your%20repo%22%20with%20this%20VM%27s%20name%20and%20the%20repo%2C%20and%20wait.%0AWrite%20app-backup.sh%20for%20the%20databases%20the%20stack%20really%20runs%2C%20and%20test%0Aa%20restore%20into%20a%20scratch%20database.%20To%20check%20a%20failing%20push%2C%20push%20a%0Acommit%20that%20breaks%20the%20build%20from%20a%20scratch%20clone%3A%20it%20must%20be%0Arejected.%20Remove%20your%20scratch%20clones%20and%20test%20scripts%20when%20done.%0AFinish%20with%20https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%2C%20any%20other%20ports%20the%20app%20publishes%2C%0Aand%20only%20the%20one%20git%20command%20from%20%22Deploy%20with%20git%20push%22%20that%20fits%3A%0A%60git%20clone%20-o%20exe%60%20for%20REPO%3Dnew%2C%20%60git%20remote%20add%20exe%60%20for%20a%20repository.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open%20and%20these%20commands%2C%20which%20only%20I%20can%20run%2C%20from%20my%20own%20computer%20%28with%20this%20VM%27s%20name%20in%20them%29%3A%0A%20%20git%20clone%20-o%20exe%20my-docker-compose.exe.xyz%3Aapp.git%20my-app%20%20%20%23%20if%20you%20started%20fresh%0A%20%20git%20remote%20add%20exe%20my-docker-compose.exe.xyz%3Aapp.git%20%20%20%20%20%20%20%20%23%20or%2C%20in%20your%20own%20repo%27s%20checkout%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe-staging.dev/deploy/docker-compose/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Then, on your own computer:

```sh
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
```

Or follow the steps below by hand.

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.

## 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](/docs/sharing), 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:

```bash
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:

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

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

```text
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:

```text
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:

```text
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:

```bash
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:

```yaml
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](/docs/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:

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

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

```bash
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:

```bash
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](/deploy/postgres), and let your containers reach it as
[shown there](/deploy/postgres#from-a-docker-container).

## 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:

```bash
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](#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](/docs/integrations-oci-registries),
  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:

```bash
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:

```bash
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](/docs/cnames)). 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](/docs/login-with-exe)).
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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Install by hand</summary>

### Create the VM

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

```bash
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:

```bash
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:

```bash
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:

```bash
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.

```bash
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.

```bash
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:

```bash
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`:

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

Skip to [Set up backups](#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](/docs/integrations-github), with no token on the VM:

```bash
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](https://exe.dev/integrations) if you haven't, then
run this with your repository, and try the clone again:

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

Deploy the repository's default branch:

```bash
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:

```bash
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](#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](https://github.com/dockersamples/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`:

```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:

```bash
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?](#troubleshooting), 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:

```bash
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](#other-databases)) and exclude their directories in
`~/data` from the `tar`:

```bash
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:

```bash
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:

```bash
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](#open-your-app).

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

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:

```bash
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](#create-the-vm) 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:

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

On the VM:

```bash
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](#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:

```bash
# 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.

</details>

<details id="update" class="dp-details">
<summary>Update</summary>

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.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

Start on the VM:

```bash
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](#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](#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](/docs/faq/ssh-destination)).
- **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`.

</details>

## 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](/docs/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](/docs/integrations-github) clones private repos with no token on the VM, and exe.dev's [registry integrations](/docs/integrations-oci-registries) 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](https://exe.dev/pricing).


## Related

- [Use exe.dev as a remote Docker host](https://exe-staging.dev/deploy/docker-host.md): Keep the docker CLI. Send the builds, the containers, and the fan noise to a VM.
- [Self-host PostgreSQL](https://exe-staging.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Self-host n8n](https://exe-staging.dev/deploy/n8n.md): Connect your apps. Let the boring bits run themselves.
- [Self-host Uptime Kuma](https://exe-staging.dev/deploy/uptime-kuma.md): A little lookout for your websites. Green bars encouraged.
- [Deploy a Django app](https://exe-staging.dev/deploy/django.md): Your Django app on a computer of its own. git push to deploy.
- [Deploy a Laravel app](https://exe-staging.dev/deploy/laravel.md): Your Laravel app on a server of its own. git push to deploy.
