Push Button Deploy

Bootstrap an app, its repository, and its CI pipeline. Deploy web apps to DigitalOcean.

I wanted to start a project with deployment already working. I was repeating the same setup for each new app, so I put it into a bootstrap script. It generates the app, creates its repository, wires up CI, and waits for the first deployment to answer over HTTPS.

Starting a project

Once the tools and credentials are configured, this creates and deploys a Phoenix app:

./bootstrap.sh ~/src/myapp

Phoenix is the default. Sinatra and Zola are also supported. If the directory already contains an app for the chosen framework, the script uses it instead of generating one. --check verifies the prerequisites without provisioning anything, and --interactive walks through the choices.

It also sets up command-line tools and libraries:

./bootstrap.sh --cli ruby ~/src/mytool
./bootstrap.sh --cli typescript ~/src/myothertool
./bootstrap.sh --no-droplet ~/src/mylib

CLIs can use Elixir, Ruby, Bash, or TypeScript. Libraries currently use Elixir. These runs create a repository and CI pipeline, then wait for CI to pass. They don’t provision infrastructure or need DigitalOcean credentials.

What goes into the repository

The generated project carries its own workflows and infrastructure files. Terraform runs from the app’s repository, so infrastructure changes can be reviewed alongside application changes. Re-running bootstrap picks up work already done and preserves the app’s existing infrastructure files.

For a web service, Terraform state is split into three roots: the state bucket, persistent resources, and disposable compute. The reserved IP, DNS records, and managed Postgres cluster live separately from the droplet and firewall. Replacing the droplet doesn’t destroy that cluster.

SQLite is the default database. Litestream replicates it to Spaces and restores from the replica when the droplet is recreated. Phoenix can use managed Postgres instead; Sinatra uses SQLite, and Zola has no database.

Deployments and staging

Phoenix and Sinatra run tests in CI, build a container image tagged with the commit SHA, and run migrations before switching versions. The new blue/green container has to pass its health check before the old one stops. Caddy handles HTTPS and retries requests during the swap. Rollback uses a previous image without rebuilding it.

Zola takes a different path. CI builds the site and uploads the files as a release. An atomic symlink change publishes it; rollback points the symlink at a retained release. This site uses that path.

On GitHub, Phoenix and Sinatra also get staging on pull requests. Each app has one staging slot on the same droplet. The latest PR to deploy owns it, and closing that PR tears it down. Staging uses separate data from production. It isn’t available for Zola or Gitea.

Sharing a droplet

--host puts another app on an existing app’s droplet. The tenant adds its own DNS record and application files, using the host’s infrastructure. Dynamic tenants use SQLite, with separate volumes and Litestream prefixes. Static sites can be tenants too.

One host-owned Caddy serves all the apps. CPU and memory are shared without enforced limits, so the droplet needs enough capacity for all of them.

Code hosting and project guidance

GitHub is the default code host and CI provider. Gitea is also supported, and a separate bootstrap script can provision a Gitea instance with an Actions runner.

Generated Phoenix, Sinatra, and Zola projects include CLAUDE.md and framework-specific guidance. Phoenix and Sinatra can also include starter test-writer and code-reviewer agents and hooks. The docs generator can run separately to add guidance to an existing project without provisioning anything.