← All guides
Development and Deployment

Run CI builds on disposable Droplets

Use ephemeral CI build machines for trusted GitHub Actions jobs. Build on a fresh Droplet, save artifacts, and verify cleanup with a practical starter project.

Jump to stepsView on GitHub

Build once, keep the artifact, delete the machine

Ephemeral CI build machines give each build a fresh operating system, then disappear when the work finishes. This guide uses a DigitalOcean Droplet as a single-job GitHub Actions runner. You will run a small Python test suite, upload a downloadable artifact, and verify that the machine has been deleted.

The distinction matters. GitHub removes an ephemeral runner registration after one job. It does not destroy the virtual machine. A green workflow can still leave a server accumulating charges, so deletion is part of the build procedure.

The first run provisions the Droplet manually so you can inspect each boundary. The companion automates the build, normal deletion, and cleanup of abandoned demo machines. It is a teaching project for trusted code in a private GitHub.com repository, not a production autoscaler or a GitHub Enterprise Server example.

When ephemeral CI build machines are worth operating

I would keep GitHub-hosted runners for ordinary tests unless there is a concrete reason to manage machines. Droplets become useful when you need a particular Linux environment, more control over installed packages, or occasional builds with a different memory requirement. Owning the image also means owning updates and cleanup.

For this exercise, choose a private repository whose collaborators and workflow changes you trust. A disposable VM limits persistence between jobs; it does not make malicious code safe during a job. Do not attach production databases, deployment keys, or a shared Docker socket. Do not use pull_request_target to run a contributor branch with privileged credentials.

Scroll horizontally to see all columns.

Choose by the operational work you actually need
ApproachUseful forTradeoff
GitHub-hosted runnerRoutine CI with little machine administrationAvailable environments and plan allowances determine the fit
Fresh Droplet per jobCustom Linux setup and isolated build disksBoot time, image maintenance, deletion and billing checks
Persistent self-hosted runnerWarm tools and caches for frequent trusted jobsState and credentials can survive between builds

Budget for creation through deletion

DigitalOcean currently bills CPU Droplets per second, with a minimum charge of 60 seconds or US$0.01, whichever is higher. Billing starts at creation and ends at destruction. Powering a Droplet off does not end the charge. Check the selected region, configuration and current hourly rate before creating it.

For a hypothetical US$0.06 hourly machine that exists for 12 minutes, compute is US$0.012 per run. One hundred separate runs would be US$1.20 before other charges. This is arithmetic, not a quoted Droplet plan or a measured build time. Apply the minimum separately to each Droplet, and include installation, queueing and artifact upload in its lifetime.

Artifact storage, GitHub-hosted cleanup minutes, snapshots and network transfer can add costs. Short-lived machines do not earn a full month of outbound transfer allowance. Avoid snapshots, backups, extra volumes and reserved IPs in this exercise. Set a billing alert, but treat it as a notification rather than a spending cap.

Prepare the private repository and cleanup controller

Download the companion and copy its files into a new private repository with main as the default branch. Move workflows/build.yml and workflows/cleanup.yml into .github/workflows/. Keep cleanup.py and tests/ at the repository root. Commit these files to main. The public companion deliberately keeps cloud workflows inactive.

On your computer, use Python 3.10 or newer and run the command below from the companion directory. It makes no cloud requests. The sample build runs the same tests on Ubuntu and packages the script with a commit identifier, so there is a concrete artifact to inspect before you substitute your application.

In DigitalOcean, create a cleanup API token with droplet:read and droplet:delete scopes. In your private GitHub repository, open Settings, Secrets and variables, Actions. Add the token as the repository secret DO_CLEANUP_TOKEN. Add the variable DO_CI_OWNER_TAG with a unique value such as ci-owner-my-private-repo. Reserve that value for this exercise.

Leave DO_CI_CLEANUP_ENABLED unset initially. Scheduled cleanup will inspect without deleting. Run the cleanup workflow manually with apply unchecked and confirm it can list resources. The normal post-build cleanup still deletes the exact supplied demo ID. DigitalOcean token scopes are not restricted by these tags; tags are safeguards enforced by the script. A separate DigitalOcean team provides a stronger account boundary.

On your computer, inside the companionLocal terminal
python3 -m unittest discover -s tests -v

Create and identify one disposable Droplet

In the DigitalOcean Control Panel, create an Ubuntu 24.04 x64 CPU Droplet. Start with enough memory for the small Python example, such as 2 GiB; that is a starting configuration, not sizing advice for a large application. Choose an available plan and inspect its displayed price. Use an SSH key that you already control.

Name it ci-demo-first and add both tags, ci-ephemeral-demo and the exact DO_CI_OWNER_TAG value. Record its numeric Droplet ID, public IP and creation time outside the VM. The ID is visible in the Control Panel URL when you open the Droplet. Confirm the ID and name refer to this demo before using them in a workflow.

Apply a Cloud Firewall that allows inbound SSH on TCP 22 only from your current public IP. Leave outbound access available for this trusted demonstration so package downloads, DNS and GitHub HTTPS requests can complete. Do not expose ports 80, 443 or a Docker daemon as inbound services. The runner contacts GitHub outward; GitHub does not need an inbound connection to assign jobs.

Use a separate VPC without production peers or services for the exercise. The firewall and the absence of deployment credentials reduce accidental access, but do not turn the build machine into a sandbox for hostile code.

Install the runner as an unprivileged user

Connect over SSH as root using the public IP. On a first connection, verify the server fingerprint through a trusted channel such as the DigitalOcean console before accepting it. In the console, ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub displays the ED25519 fingerprint. Do not disable host-key checking to avoid this step.

Run the setup below on the Droplet. Root installs packages and creates runner, an account without sudo rights. The final command switches your shell to that account. Keep this terminal open during the exercise.

In your private GitHub repository, open Settings, Actions, Runners, New self-hosted runner. Select Linux and x64. As the runner user, execute the download, checksum verification and extraction commands GitHub currently displays. Do not copy its default configuration or service-install commands yet. Use the current package instead of an old version embedded in a tutorial.

If GitHub’s prerequisite check reports missing libraries, exit back to the root shell and run /home/runner/actions-runner/bin/installdependencies.sh, then return with su - runner. Only the dependency installation needs root. Never solve a runner startup error by enabling root execution or granting the build user unrestricted sudo.

Before continuing: Run as root on the new demo Droplet only. The final command switches to runner.

On the Droplet, as rootConnected Droplet
apt-get update
apt-get install -y ca-certificates curl git python3
adduser --disabled-password --gecos "" runner
su - runner

Register a runner for exactly one job

Enter the extracted actions-runner directory. The registration token shown by GitHub is short-lived. Read it into the hidden prompt below rather than pasting it into a saved script or shell history. Replace OWNER/PRIVATE_REPO with your private repository path. The token can briefly exist in process arguments during configuration, so keep access to this demo machine restricted.

The custom label ci-demo-first routes this demonstration to its own runner. Choose a new label for every new machine and use the same label when dispatching the workflow. Labels select machines; they are not authorization controls. Register this runner only with the dedicated private repository.

The --ephemeral flag is essential. After one job, the runner deregisters. Do not install a persistent service or wrap run.sh in a restart loop. Run it in the foreground and look for the message that it is listening for jobs. The process can exit or the SSH connection can drop when the hosted cleanup job destroys the Droplet.

Before continuing: Run as the unprivileged runner user in Bash. Keep the registration token out of saved files.

On the Droplet, as runner, in BashConnected Droplet
Edit firstReplace every highlighted value before running this command.
cd ~/actions-runner
read -r -s -p "Runner registration token: " RUNNER_TOKEN
printf "\n"
./config.sh --unattended --url https://github.com/OWNER/PRIVATE_REPO \
  --token "$RUNNER_TOKEN" --ephemeral \
  --name ci-demo-first --labels ci-demo-first
unset RUNNER_TOKEN
./run.sh

Replace before use: OWNER/PRIVATE_REPO

Dispatch the build and inspect its artifact

Open Actions in your private repository, select Disposable build, and choose Run workflow on main. Enter ci-demo-first as runner_label and the recorded numeric ID as droplet_id. These must describe the same machine. The workflow accepts private repositories on main only, declares read-only repository contents access, and limits the build to 20 minutes after it starts.

The example runs tests and creates dist/commit.txt, dist/python-version.txt and a source archive. It then uploads dist as a three-day artifact. Download that artifact from the workflow summary and compare commit.txt with the commit shown for the run. A green test step without a downloadable artifact is not a complete result.

The cleanup job uses ubuntu-24.04 on GitHub’s infrastructure and runs after the build with always(). Only that job receives the DigitalOcean token. It rechecks the requested ID, name prefix and both tags, deletes the matching machine, and polls until the API reports it absent. A delete request by itself is not treated as confirmation.

Cancellation, an unavailable hosted runner or a GitHub outage can still prevent this job from finishing. If a build remains queued, verify the runner is listening and its label matches. The build timeout does not bound the time spent waiting for a suitable runner.

Recover from a failed cleanup

Open the Droplet list after the run and confirm ci-demo-first is gone. Check GitHub’s runner list too. If the VM remains, inspect the failed cleanup logs and use the Control Panel to destroy only that demo, or run the companion locally with the exact ID.

For local cleanup, read the DigitalOcean token into your local Bash session using the hidden prompt below. Replace the owner tag and numeric example ID with your recorded values. The first call prints what it would delete. Add --apply only after the printed ID and name match. Then unset the token. None of these commands belongs on the build machine.

A 401 or 403 is an authentication or permission failure, not proof that the VM disappeared. The script fails on those responses. If deletion cannot be confirmed, check the Control Panel and resolve the outstanding resource before creating another demo.

On your computer, in Bash, inside the companionLocal terminal
Edit firstReplace every highlighted value before running this command.
read -r -s -p "DigitalOcean cleanup token: " DIGITALOCEAN_TOKEN
printf "\n"
export DIGITALOCEAN_TOKEN
python3 cleanup.py --owner-tag ci-owner-my-private-repo --id 123456789

Replace before use: ci-owner-my-private-repo, 123456789

Before continuing: The --apply command permanently deletes the matching demo Droplet and its local disk. Run the dry run first and verify the exact ID and name.

After verifying the dry-run resultLocal terminal
Edit firstReplace every highlighted value before running this command.
python3 cleanup.py --owner-tag ci-owner-my-private-repo --id 123456789 --apply
unset DIGITALOCEAN_TOKEN

Replace before use: ci-owner-my-private-repo, 123456789

Enable the independent age-based cleanup

The second workflow scans every half hour, at minutes 17 and 47. It selects only names beginning ci-demo-, with both the managed tag and your owner tag, that are at least two hours old. It does not check whether a build is active. The two-hour threshold is an absolute demo lifetime and can terminate a stalled or still-running job.

First leave a disposable, correctly tagged test machine beyond the threshold and run the cleanup workflow with apply unchecked. Verify that it lists the expected machine and skips unrelated resources. Then run it with apply checked, confirm absence, and set the repository variable DO_CI_CLEANUP_ENABLED to true to enable scheduled deletion.

The schedule is a fallback, not a two-hour spending guarantee. GitHub documents delayed and dropped scheduled jobs during high load. Check that the workflow is on the default branch and still enabled, and monitor failures. A production controller should reconcile resources from outside the build VM and alert when the oldest owned machine exceeds its expected lifetime.

Replace the sample without mixing build and deployment

Once the demo passes, replace its test and archive commands with your application’s locked dependency installation, tests and build. Install the required compiler or runtime in the machine image. Start with a clean dependency download; introduce caching only after you have measured its benefit and separated trusted cache writers from untrusted code.

For continuous delivery, let a separate job download the successful artifact and deploy the exact recorded commit through your normal approval process. Put deployment credentials in that job’s protected environment. Giving the build Droplet production credentials removes a useful boundary without making compilation easier.

Before automatic provisioning, add a controller that creates a fresh VM per job, records its ID before registration, supplies a short-lived runner registration credential and waits for readiness. Use unique job labels, a maximum machine count and idempotent cleanup. Keep the long-lived GitHub administration credential and DigitalOcean token off the VM. Never bake a registered runner or its credentials into an image.

Forward runner diagnostic logs before destroying production machines, as GitHub recommends for ephemeral runners. The demo retains workflow logs and the uploaded artifact, but does not implement external collection of the runner’s _diag directory. Test success, build failure, cancellation and a runner that never registers before calling the system unattended.

Keep it only if the lifecycle earns its upkeep

The payoff is a fresh build disk and control over the Linux environment. The bill includes the operational work of keeping that environment patched, getting artifacts out and deleting machines when something fails. For short, ordinary jobs, a hosted runner may be the better purchase even if the VM arithmetic looks cheaper.

Use this pattern when that control solves an actual build constraint. Your acceptance record should contain the tested commit, downloaded artifact, runner removal, confirmed Droplet absence and the result of an abandoned-machine cleanup rehearsal. Those checks establish a working lifecycle; a successful compilation alone does not.

Does --ephemeral delete the Droplet?

No. It makes GitHub assign one job and remove the runner registration afterward. The VM remains until a controller or an operator deletes it through DigitalOcean. This guide uses a hosted cleanup job plus an age-based fallback and verifies the machine is absent.

Can I run pull requests from public forks?

Do not use this teaching setup for untrusted pull requests. Disposable machines still execute code with whatever credentials and network access they have during the job. Start with a private repository and trusted workflow changes. Keep public contribution testing on an appropriately isolated hosted service.

Does shutting down the machine stop billing?

No. Destroy the Droplet to end its compute billing. Current CPU billing is per second with a minimum of 60 seconds or US$0.01, whichever is higher. Include setup and queue time, and check separate storage and transfer charges.

Is a new Droplet created automatically?

No. The first run creates and registers one machine manually. The companion automates testing, artifact upload, post-build deletion and stale-machine cleanup. Automatic provisioning needs a separate controller with registration credentials, readiness checks, resource limits and durable tracking of each created VM.

What happens if cleanup misses its schedule?

The Droplet can remain billable. Scheduled workflows may be delayed or dropped, so the two-hour threshold is not a guaranteed spending cap. Check the control panel, delete the exact demo manually when needed, and monitor cleanup failures before leaving the process unattended.

Check your result

Expected result
The artifact identifies the tested commit; the runner deregisters and the API confirms the Droplet is absent.
Stop if
A green build with a remaining VM is incomplete. Inspect cleanup errors, then delete the exact owned demo.
Next step
Rehearse age-based cleanup before adapting the build or adding automatic provisioning.