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