I do most of my development work in ephemeral VMs, because sometimes I need large machines and it's also good to maintain an air-gap separation when letting an AI agent loose. So I'm regularly spinning up new machines with similar configurations - applying security hardening, pulling git repos, and installing software (e.g. OpenCode).

I also like to code from my phone, which means sometimes I want to spin up a VM while I have limited access to developer tooling or documentation.

To make this efficient, I created a set of "1-click" setup IaC scripts (named after the DigitalOcean WordPress 1-Click which was one of my first deployments back in 2022) which codify project-specific VMs and handle their lifecycles (i.e. spin-up, monitor, teardown).

Architecture

All 1-click scripts share a common utils layer which gets applied on every new machine. The layout looks like this:

1-clicks/
├── utils.sh
├── harden_vm.sh
├── opencode/
   ├── setup.sh
   └── .env
├── pokebot/
   ├── setup.sh
   └── .env
└── rag-system/
    ├── setup.sh
    └── .env

Each setup.sh sources utils.sh and its own .env, calls the shared layer, then runs project-specific steps:

source "$ROOT_DIR/utils.sh"
load_env

create_droplet $NAME
harden_droplet
load_env  # re-load to pick up IP and PASSWORD which is appended here by create_droplet

# project-specific steps below...

The shared layer handles the repeatable parts: creating the droplet via the DigitalOcean API, waiting for an IP, waiting for SSH to come up, clearing apt locks, running cloud-init status --wait, then uploading and running harden_vm.sh. By putting all the boilerplate in a single layer, it makes the bulk of each project's setup.sh a clear, self-contained list of its own dependencies and nothing else - very easy to see what each project requires.

Examples

Project Ignis — a simple example where the project-specific section is all handled on the remote (utilising a heredoc to push commands over SSH): creating directories, downloading a binary release from GitHub, extracting it, and setting permissions:

# project-specific steps below...
ssh $NEW_USER@$IP << EOF
set -e
mkdir -p ~/YGO/EDOPro
cd ~/YGO/EDOPro
wget https://github.com/ProjectIgnis/edopro-assets/releases/download/41.0.2/ProjectIgnis-EDOPro-41.0.2-linux.tar.gz
tar -xzf ProjectIgnis-EDOPro-41.0.2-linux.tar.gz --strip-components=1
rm ProjectIgnis-EDOPro-41.0.2-linux.tar.gz
chmod +x ./EDOPro
EOF

RAG system — an alternative where processing happens locally; files are then synced over and extracted on the remote:

# project-specific steps below...
cd "$KNOWLEDGE_BASE_PATH" && find . -type f -name "*.md" -print0 \
  | tar --null -T - -czf /tmp/rag-kb.tar.gz

rsync -avz --progress /tmp/rag-kb.tar.gz $NEW_USER@$IP:/tmp/

ssh $NEW_USER@$IP << EOF
tar -xzvf /tmp/rag-kb.tar.gz -C ~/rag-system/local/data
EOF

OpenCode — a hybrid example. Installation of the OpenCode CLI happens on the remote, while a config file is created locally and then pushed via scp (DeepSeek V4 Flash is my current go-to cheap model when I'm out of Claude tokens):

# project-specific steps below...
ssh $NEW_USER@$IP << 'EOF'
curl -fsSL https://opencode.ai/install | bash
EOF

cat > /tmp/opencode-config.json << CONFIG
{
  "provider": {
    "openrouter": { "options": { "apiKey": "${OPENROUTER_API_KEY}" } }
  },
  "model": "openrouter/deepseek/deepseek-v4-flash"
}
CONFIG

scp /tmp/opencode-config.json $NEW_USER@$IP:~/.config/opencode/opencode.json

These steps can also be modularised and chained. At the end of the Ignis setup, rather than re-implementing the OpenCode install, the script just calls it:

# ...after all the Ignis-specific steps...
setup_opencode

Local convenience commands

One side benefit of having a .env per project is that local shell commands become generic. Rather than copying and pasting a freshly-provisioned IP every time (like I used to do when SSHing!), I can do:

source ./1-clicks/project-xyz/.env && ssh "sam@$IP"

The same applies to teardown:

source ./1-clicks/project-xyz/.env && delete_droplet $DROPLET_NAME

Going from "SSH to an IP that needs to be copied from somewhere" to "one command that always works" is a small QoL improvement but applies across almost all dev sessions.

Some tricks

Waiting for the VM to be ready is more annoying than it sounds. A freshly created droplet goes through several stages before it's actually usable:

# 1. Poll until SSH is up
until ssh -o ConnectTimeout=5 root@$IP "echo ok" &>/dev/null; do sleep 2; done

# 2. Wait for cloud-init to finish
ssh root@$IP "cloud-init status --wait"

# 3. Wait for apt locks to clear
ssh root@$IP << 'EOF'
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 2; done
EOF

Skipping any of these can cause apt install to fail opaquely.

Sudo via pipe — hardening disables password SSH, so for non-interactive sudo in scripts I store the password in .env and pass it via:

echo "$PASSWORD" | sudo -S apt install -y some-package

This is obviously a pattern to use carefully (password is being stored in plaintext in an env var, it appears briefly in the system's process list, etc.) — but on an ephemeral VM that gets torn down afterwards, it's an acceptable trade-off.

Timestamped droplet names — appending $(date +%s) to the project name avoids collisions when spinning up multiple instances and makes it easy to see which is newest in the console.

Reusing an existing droplet — when iterating on the project-specific steps, it's wasteful/slow to tear down and recreate a VM every time. A --reuse-droplet <IP> flag lets you skip the creation and hardening steps and jump straight to the project section, which makes the iteration loop much faster:

if [[ "${1:-}" == "--reuse-droplet" ]]; then
  IP="$2"
else
  create_droplet $NAME
  harden_droplet
  load_env
fi

# project-specific steps (always run)...