---
title: "Use exe.dev as a remote Docker host"
description: "Point docker context or DOCKER_HOST at an exe.dev VM over SSH: native amd64 builds from your Mac, Compose, and a warm build cache off your laptop."
url: https://exe.dev/deploy/docker-host
category: "Developer tools"
updated: 2026-10-17
---

# Use exe.dev as a remote Docker host

Keep the docker CLI. Send the builds, the containers, and the fan noise to a VM.

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

## What you get

- Docker Engine enabled at boot, so containers with a restart policy come back after a reboot.
- Docker usable by `exedev` without sudo, which is the account your docker CLI uses over SSH.
- Container logs capped at 30 MB each, and a daily prune of unused images and build cache.

Running on the VM:

- **Docker Engine 29**: preinstalled on exeuntu, with Buildx and Compose; starts at boot, data in `/var/lib/docker`
- **SSH**: your docker CLI runs `docker system dial-stdio` over exe.dev's SSH; no Docker port is opened
- **Daily prune**: a systemd timer that clears unused images and old build cache

How it is reached:

- ssh://my-docker-host.exe.xyz (private): The Docker API, for your docker CLI, over SSH with your exe.dev key
- https://my-docker-host.exe.xyz:8080/ (private): Any HTTP port you publish from 3000 to 9999 (8080 in the examples)

## Deploy with Shelley

Open https://exe.dev/new?prompt=Set%20up%20Docker%20Engine%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fdocker-host.md%20from%20top%20to%20bottom.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-docker-host.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-docker-host.%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-%20Docker%20Engine%20enabled%20at%20boot%2C%20so%20containers%20with%20a%20restart%20policy%20come%20back%20after%20a%20reboot.%0A-%20Docker%20usable%20by%20%60exedev%60%20without%20sudo%2C%20which%20is%20the%20account%20your%20docker%20CLI%20uses%20over%20SSH.%0A-%20Container%20logs%20capped%20at%2030%20MB%20each%2C%20and%20a%20daily%20prune%20of%20unused%20images%20and%20build%20cache.%0A%0ARead%20the%20whole%20recipe%2C%20including%20closed%20details%2C%20but%20run%20only%20%22Set%20up%0Athe%20VM%20by%20hand%22%2C%20skipping%20VM%20creation.%20Everything%20else%20%28docker%20context%2C%0A~%2F.ssh%2Fconfig%2C%20buildx%2C%20Compose%2C%20ssh%20-L%29%20runs%20on%20the%20owner%27s%20computer%2C%0Anot%20on%20this%20VM.%20Use%20the%20preinstalled%20Docker%3B%20do%20not%20install%20Docker%20from%0Aanother%20source%2C%20change%20sshd%2C%20or%20expose%20the%20Docker%20API%20on%20TCP.%20Do%20not%0Ainstall%20qemu-user-static%20unless%20the%20owner%20asks%20for%20arm64%20builds.%0AVerify%3A%20docker.service%20is%20enabled%3B%20%60sudo%20-iu%20exedev%20docker%20info%60%20works%3B%0Aa%20new%20container%20gets%20max-size%2010m%20and%20max-file%203%20log%20options%3B%0Adocker-prune.timer%20is%20enabled%20and%20docker-prune.service%20last%20ran%20with%0Aresult%20%22success%22.%20Leave%20no%20test%20containers%20behind.%20Do%20not%20reboot.%0AFinish%20with%20this%20VM%27s%20CPUs%2C%20memory%2C%20and%20free%20disk%20%28nproc%2C%20free%20-h%2C%0Adf%20-h%20%2F%29%2C%20the%20%60ssh%20exe.dev%20resize%60%20command%20from%20%22Room%20to%20grow%22%20with%0Athis%20VM%27s%20name%2C%20and%20the%20commands%20for%20the%20owner%27s%20computer.%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%20ssh%20my-docker-host.exe.xyz%20docker%20version%0A%20%20docker%20context%20create%20exe%20--docker%20%22host%3Dssh%3A%2F%2Fmy-docker-host.exe.xyz%22%0A%20%20docker%20context%20use%20exe%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

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

Then, on your own computer:

```sh
ssh my-docker-host.exe.xyz docker version
docker context create exe --docker "host=ssh://my-docker-host.exe.xyz"
docker context use exe
```

Or follow the steps below by hand.

Docker's CLI can drive an engine on another machine over plain SSH: set
`DOCKER_HOST=ssh://...` or create a `docker context`, and every `docker`
command runs there instead. An exe.dev VM makes a good other machine. It's
always on, it already runs Docker, and exe.dev's SSH already knows your key,
so there's no port to open and no TLS certificate to manage.

Put it to work for native `linux/amd64` builds from an Apple Silicon Mac,
for a laptop that would rather not run Docker Desktop, for heavy containers
and test databases that keep running when the lid closes, or as a shared
build box whose cache stays warm.

## Connect your docker CLI

After Deploy, Shelley has Docker starting at boot and cleaning up after
itself. The rest happens **on your computer**, which needs the `docker`
CLI (see [below](#get-the-docker-cli) if you only want the CLI).

First, check that SSH reaches the VM's Docker. Use your VM's name:

```bash
ssh my-docker-host.exe.xyz docker version
```

Accept the host key if asked. You should see a `Server:` section with
`OS/Arch: linux/amd64`. Now create a context named `exe` and switch to it:

```bash
docker context create exe --docker "host=ssh://my-docker-host.exe.xyz"
docker context use exe
docker info --format '{{.Name}} {{.Architecture}}'
docker run --rm alpine uname -m
```

The last two lines print `my-docker-host x86_64` and `x86_64`: that
container ran on the VM. Every `docker`, `docker compose`, and
`docker buildx` command now goes there.

To switch back, run `docker context use default` (or `desktop-linux` with
Docker Desktop). To use the VM for one command or one shell only, skip
`context use`:

```bash
docker --context exe ps
export DOCKER_HOST=ssh://my-docker-host.exe.xyz   # this shell only; overrides any context
```

If `ssh my-docker-host.exe.xyz` doesn't connect from your network, use
the [other form of the address](/docs/faq/ssh-destination), which always
works: `host=ssh://vm+my-docker-host@vm.exe.xyz`.

<details id="get-the-docker-cli" class="dp-details">
<summary>Only want the docker CLI?</summary>

You don't need a local engine.

- **macOS:** `brew install docker docker-buildx docker-compose`. Homebrew
  installs the plugins where Docker doesn't look, so add this to
  `~/.docker/config.json` (create the file if it's missing):

  ```json
  {
    "cliPluginsExtraDirs": ["/opt/homebrew/lib/docker/cli-plugins"]
  }
  ```

  On an Intel Mac the directory is `/usr/local/lib/docker/cli-plugins`.
- **Linux:** add [Docker's apt or dnf repository](https://docs.docker.com/engine/install/)
  and install only `docker-ce-cli`, `docker-buildx-plugin`, and
  `docker-compose-plugin`, or use the
  [static binaries](https://docs.docker.com/engine/install/binaries/).
- **Windows:** install the Linux CLI inside WSL.

Check with `docker --version`, `docker buildx version`, and
`docker compose version`.

</details>

## Make it fast

Each docker command opens its own SSH connection, and the handshake is
most of the wait. Let commands share one connection instead: add this to
`~/.ssh/config` on your computer:

```
Host my-docker-host.exe.xyz
  ControlMaster auto
  ControlPath ~/.ssh/cm-%C
  ControlPersist 10m
  ServerAliveInterval 30
```

The first command connects; the rest reuse the connection until it has
been idle for ten minutes, so `docker ps` comes back about as fast as
the round trip to the VM. If you use the `vm+my-docker-host@vm.exe.xyz`
form, add `vm.exe.xyz` to the `Host` line.

If docker commands ever hang (say, after your laptop wakes up on another
network), close the shared connection and try again:

```bash
ssh -O exit my-docker-host.exe.xyz
```

## Build amd64 images on an Apple Silicon Mac

With the `exe` context active, builds run on the VM's x86-64 CPUs, with
no emulation:

```bash
docker buildx build --platform linux/amd64 -t myapp:dev .
```

BuildKit uploads only the files your Dockerfile uses, and on later
builds only the files that changed, so the second build sends bytes,
not megabytes. A `.dockerignore` that leaves out `node_modules`, `.git`,
and build output keeps the first upload small, too.

The image ends up **on the VM**, ready to `docker run` there. To get it
somewhere else:

- **Push to a registry.** Run `docker login` on your computer as usual;
  credentials stay on your computer and are passed along with each push.
  Then add `--push` to the build, with a full tag like
  `ghcr.io/you/myapp:dev`.
- **Copy files out of a build** (a compiled binary, a static site):
  `--output type=local,dest=./out` writes the final stage's files to
  `./out` on your computer.
- **Load it into a local engine**, if you run one:
  `docker save myapp:dev | docker --context desktop-linux load`.

The build cache stays on the VM between builds, so dependency layers
are already warm the next time, whichever computer you build from.

<details id="arm64" class="dp-details">
<summary>Build arm64 too</summary>

The VM can build `linux/arm64` under QEMU emulation, which is slower
than native but fine for many images. Install the emulators once; they
load again at every boot:

```bash
ssh my-docker-host.exe.xyz 'sudo apt-get update && sudo apt-get install -y qemu-user-static'
docker buildx ls
```

The `exe` builder now lists `linux/arm64`. Build and push both
architectures as one image:

```bash
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/you/myapp:dev --push .
```

To build arm64 natively on your Mac at the same time, make a builder
with one node on each machine; see Docker's
[multi-platform guide](https://docs.docker.com/build/building/multi-platform/).

</details>

## Run Compose on the VM

`docker compose` reads `compose.yaml` and `.env` on your computer and
starts the containers on the VM. Try a web server and a test database:

```bash
mkdir -p ~/exe-compose-demo && cd ~/exe-compose-demo
echo "POSTGRES_PASSWORD=$(openssl rand -hex 16)" > .env
cat > compose.yaml <<'EOF'
services:
  web:
    image: nginx:stable-alpine
    ports: ["8080:80"]
  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports: ["127.0.0.1:5432:5432"]
    volumes: [pgdata:/var/lib/postgresql]
volumes:
  pgdata:
EOF
docker compose up -d --wait
docker compose ps
```

Both services should be running (`--wait` returns once they are).
The ports are published **on the VM**:

- **HTTP:** open `https://my-docker-host.exe.xyz:8080/` and sign in to
  exe.dev; you'll see nginx's welcome page. Any port from 3000 to 9999
  works the same way, privately, through exe.dev's
  [HTTPS proxy](/docs/proxy). Port 8000 is also
  `https://my-docker-host.exe.xyz/`.
- **Everything else** (Postgres, Redis, gRPC) needs an SSH tunnel.
  This maps port 15432 on your computer to the database:

  ```bash
  ssh -fN -L 15432:localhost:5432 my-docker-host.exe.xyz
  . ./.env && psql "postgres://postgres:$POSTGRES_PASSWORD@localhost:15432/postgres" -c 'select version()'
  ```

  Any Postgres client works; `psql` prints the server's version. The
  tunnel runs in the background. Close it with
  `ssh -O cancel -L 15432:localhost:5432 my-docker-host.exe.xyz` if you
  use the multiplexing above, or by ending that `ssh` process if not.

The database's data lives in the `pgdata` volume on the VM, and
`restart: unless-stopped` brings it back after a reboot. When you're
done, `docker compose down` stops both; add `-v` to delete the data too.

For live code changes, Compose's
[watch mode](https://docs.docker.com/compose/how-tos/file-watch/)
works against the VM: `docker compose watch` copies files you save on
your computer into the running containers, or rebuilds when you tell it
to.

## What's different with a remote engine

### Bind mounts are the VM's folders

`docker run -v "$PWD":/app` or `./src:/app` in Compose mounts that path
**on the VM**. Your laptop's files don't travel, and if the path doesn't
exist on the VM, Docker creates it there as an empty, root-owned
folder. The container starts and sees nothing. Instead:

- `COPY` files into the image; the build context is uploaded for you.
- Use `docker compose watch` (above) to sync source into a container.
- Keep the project on the VM: `git clone` it there, or
  `rsync -a ./ my-docker-host.exe.xyz:myapp/`, and run
  `ssh my-docker-host.exe.xyz 'cd myapp && docker compose up -d'`.
- Use named volumes for data. `docker cp` moves files in and out.

Compose `secrets:` and `configs:` that point at a `file:` are bind
mounts too, and fail with `bind source path does not exist`. Use
`environment:` for secrets and `content:` for configs instead; Compose
copies those into the container.

### Published ports live on the VM

`localhost:8080` in your browser is your laptop, not the VM. Use
`https://my-docker-host.exe.xyz:<port>/` for HTTP on ports 3000-9999, or
`ssh -L` for anything else. exe.dev's inbound traffic is HTTPS only:
there's no raw TCP or UDP from the internet, so a published port is
never reachable directly, even though Docker binds it to `0.0.0.0`. Pick
host ports in 3000-9999 for anything you want to open in a browser;
random ports (`-p 80` or `-P`) land above 32768, out of the proxy's
reach. Binding to `127.0.0.1`, like the database above, keeps a port
for SSH tunnels only.

### Testcontainers and dev containers

**Testcontainers** starts containers on random ports and connects to
them directly from your test process. Those ports aren't reachable from
your laptop through exe.dev, so run the test suite **on the VM** (over
SSH or in your editor's remote session), where it works with no
configuration.

**Dev containers** need the source next to the engine. In VS Code,
connect to the VM with [Remote-SSH](/docs/faq/vscode), clone your
repository there, and run **Dev Containers: Reopen in Container**. See
VS Code's [remote Docker host guide](https://code.visualstudio.com/remote/advancedcontainers/develop-remote-host).

### SSH access is Docker access

Anyone who can SSH into the VM can run any container, and through it
become root on the VM. That's why it stays behind your exe.dev key. To
turn it into a shared build box, give teammates you trust
[Root access](/docs/sharing). On your computer:

```bash
ssh exe.dev share add my-docker-host teammate@example.com --root
```

They create the same `exe` context on their computer and share your
images and warm build cache. Never expose Docker's API on a TCP port.

## Keep the disk in check

Images and build cache are what fill a Docker host. See where the space
goes:

```bash
docker system df
```

Shelley set up a daily `docker-prune` job on the VM. It removes images
that no container uses once they've been on the VM for a week, and
build cache unused for a week. If the disk is still over 80% full, it
removes every unused image and all unused build cache. It never touches
containers or volumes. Container logs are capped at 30 MB per
container. To clean up right now, or to also remove stopped containers:

```bash
ssh my-docker-host.exe.xyz sudo docker-prune
docker container prune
```

Add `--rm` to throwaway `docker run` commands so stopped containers
don't pile up. Images you want to keep belong in a registry; the VM is
a cache.

### Room to grow

A new VM has 2 vCPUs, 8 GB of memory, and a 25 GB disk, and your VMs
share your plan's CPU and memory. On your computer, give this one more,
up to your [plan's](/docs/billing/personal) capacity:

```bash
ssh exe.dev resize my-docker-host --cpu=4 --memory=16GB --disk=100GB
```

Disks can only grow. Check with `nproc`, `free -h`, and `df -h /` on
the VM. To start big instead, pass the same flags to
`ssh exe.dev new --name=my-docker-host`.

## Back up a volume

Images and build cache can be rebuilt; volumes, like the database
above, can't. Because a container's output streams back to your
computer, you can archive a volume straight onto your laptop. Stop
whatever writes to it first:

```bash
docker compose stop db
docker run --rm -v exe-compose-demo_pgdata:/data:ro alpine tar -C /data -czf - . > pgdata.tar.gz
docker compose start db
```

(Compose prefixes volume names with the project, which is the folder's
name; `docker volume ls` lists them.) To restore into a new, empty
volume:

```bash
docker volume create pgdata-restored
docker run --rm -i -v pgdata-restored:/data alpine tar -C /data -xzf - < pgdata.tar.gz
```

To copy the whole VM, run `ssh exe.dev cp my-docker-host my-docker-host-copy`
on your computer.

## The nuts and bolts

These are Shelley's setup steps, for doing it by hand or checking what
was done.

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

### Create a VM

On your computer (pick another name if `my-docker-host` is taken; add
`--cpu`, `--memory`, and `--disk` for a bigger one):

```bash
ssh exe.dev new --name=my-docker-host
ssh my-docker-host.exe.xyz
```

The remaining commands run **on the VM**, as `exedev`.

### Start Docker at boot

On exeuntu, Docker starts on demand, so containers don't come back
after a reboot until something touches Docker. Enable it at boot, and
make sure `exedev` can use it without sudo:

```bash
sudo systemctl enable --now docker.service
id -nG exedev | grep -qw docker || sudo usermod -aG docker exedev
systemctl is-enabled docker.service
sudo -iu exedev docker version --format 'Docker {{.Server.Version}}, {{.Server.Os}}/{{.Server.Arch}}'
```

You should see `enabled` and `Docker 29...` followed by `linux/amd64`.

### Cap container logs

Docker keeps every line a container prints unless told otherwise. Keep
three files of 10 MB per container. This merges into any existing
`/etc/docker/daemon.json` and applies to containers created from now on:

```bash
sudo mkdir -p /etc/docker
[ -s /etc/docker/daemon.json ] || echo '{}' | sudo tee /etc/docker/daemon.json >/dev/null
tmp=$(mktemp)
jq '. + {"log-driver": "json-file", "log-opts": {"max-size": "10m", "max-file": "3"}}' \
  /etc/docker/daemon.json > "$tmp" &&
  sudo install -m 644 "$tmp" /etc/docker/daemon.json &&
  sudo systemctl restart docker.service
rm -f "$tmp"
docker run --name log-check alpine:3 uname -m
docker inspect log-check --format '{{.HostConfig.LogConfig.Config}}'
docker rm log-check
```

Expect `x86_64` and `map[max-file:3 max-size:10m]`.

### Prune daily

```bash
sudo tee /usr/local/sbin/docker-prune >/dev/null <<'EOF'
#!/bin/sh
# Remove what Docker can pull or rebuild. Containers and volumes are never touched.
set -eu
docker image prune --all --force --filter until=168h
docker builder prune --force --filter until=168h
# Still over 80% full? Remove every unused image and all unused build cache.
if [ "$(df --output=pcent /var/lib/docker | tail -1 | tr -dc 0-9)" -ge 80 ]; then
  docker image prune --all --force
  docker builder prune --all --force
fi
df -h /var/lib/docker
EOF
sudo chmod 755 /usr/local/sbin/docker-prune
sudo tee /etc/systemd/system/docker-prune.service >/dev/null <<'EOF'
[Unit]
Description=Prune unused Docker images and build cache
Wants=docker.service
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-prune
EOF
sudo tee /etc/systemd/system/docker-prune.timer >/dev/null <<'EOF'
[Unit]
Description=Prune unused Docker images and build cache daily

[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now docker-prune.timer
sudo systemctl start docker-prune.service
systemctl list-timers docker-prune.timer
systemctl show docker-prune.service -p Result
```

The timer lists its next run, and `Result=success` means the first prune
worked. Its output is in `journalctl -u docker-prune.service`.

That's the VM done. Go back to [Connect your docker CLI](#connect-your-docker-cli)
on your computer.

</details>

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

exeuntu's Docker comes from Ubuntu's packages, so it updates with the
rest of the system. On the VM:

```bash
sudo apt-get update
sudo apt-get install --only-upgrade -y docker.io docker-buildx docker-compose-v2
docker version --format '{{.Server.Version}}'
```

The upgrade restarts Docker; containers with a restart policy come back
on their own. Your computer's CLI can be newer than the VM's engine;
they agree on an API version when they connect.

</details>

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

Start from your computer, with SSH alone:

```bash
ssh my-docker-host.exe.xyz docker version
```

- **`Permission denied (publickey)`** or a registration prompt: SSH is
  offering a key exe.dev doesn't know. See
  [which key to use](/docs/faq/ssh-key).
- **`Host key verification failed`**: connect once with
  `ssh my-docker-host.exe.xyz` and accept the key, then retry.
- **`permission denied while trying to connect to the docker API`**:
  `exedev` isn't in the `docker` group. Rerun
  [Start Docker at boot](#start-docker-at-boot) on the VM, then reconnect
  (`ssh -O exit my-docker-host.exe.xyz` if you multiplex).
- **Commands hang:** `ssh -O exit my-docker-host.exe.xyz`, then retry.
  If shared connections keep misbehaving, delete the `Control...` lines
  from `~/.ssh/config`. Everything works without them, just slower.
- **A bind-mounted folder is empty:** it's the VM's folder. See
  [Bind mounts are the VM's folders](#bind-mounts-are-the-vms-folders).
- **Containers gone after a reboot:** check
  `systemctl is-enabled docker.service` on the VM, and give containers
  `--restart unless-stopped`.
- **`no space left on device`:** `docker system df`, then
  `ssh my-docker-host.exe.xyz sudo docker-prune`, or grow the disk
  ([Room to grow](#room-to-grow)).
- **Which engine am I talking to?** `docker context show` names the
  context; `DOCKER_HOST`, if set, wins over it.

On the VM, `journalctl -u docker.service -n 50` shows the engine's log.

</details>

## Frequently asked questions

### Do I need Docker Desktop on my computer?

No. You need the `docker` CLI, plus the Buildx and Compose plugins if you use them. If Docker Desktop is installed, the CLI that comes with it works with the remote context, too.

### Is building amd64 images here faster than on my Apple Silicon Mac?

Usually much faster: the VM builds `linux/amd64` natively, while your Mac has to emulate it. For `linux/arm64` images it's the other way round; your Mac is native and the VM emulates.

### Is the Docker API exposed to the internet?

No. Docker listens only on its Unix socket inside the VM. Your docker CLI reaches it through SSH with your exe.dev key, and exe.dev doesn't forward raw TCP ports from the internet anyway.

### Do my containers keep running when I close my laptop?

Yes. They run on the VM, which stays on. Give the ones that should survive a VM reboot a restart policy such as `--restart unless-stopped`.

### Does it work on Windows?

Use the docker CLI inside WSL. The `ssh://` connection also works from Windows' own OpenSSH, but that doesn't support connection multiplexing, so every docker command opens a new SSH connection.

### How much does it cost?

It's a VM on your exe.dev plan, sharing your plan's CPU and memory with your other VMs. See [exe.dev pricing](https://exe.dev/pricing). Docker itself is free and open source.


## Related

- [Deploy a Docker Compose app from GitHub](https://exe.dev/deploy/docker-compose.md): Your compose.yaml on a VM of its own. git push to deploy.
- [Running a self-hosted GitHub Actions Runner](https://exe.dev/deploy/github-actions-runner.md): Log in easily into your CI environment
- [VS Code in your browser, with code-server](https://exe.dev/deploy/code-server.md): Your editor in a tab. Your projects right where you left them.
- [Dev, Test, Prod: Choose One, Two, or Three](https://exe.dev/deploy/dev-test-prod.md): You can build, test, and deploy on exe.dev.
- [Forgejo](https://exe.dev/deploy/forgejo.md): Self-hosted Git forge with exe.dev SSO
- [Gitea](https://exe.dev/deploy/gitea.md): Self-hosted Git forge with exe.dev SSO
