Hosting quickly 1: Setting up Terraform, Github, and 1Password
This is the first article in a series called "Hosting quickly". I want to launch Rust apps quickly and often, and I want to have stable, common infrastructure for all of them.
We'll call this Cochrane[1]. The idea is simple enough: create a template repository for quickly booting up a new Rust web project. It'll include the server, database, queues and background workers, blob store and image serving, and metrics and alerts. My first pass will all rely on SaaS companies with free tiers to minimise my ops load. However, all the technologies are open source and I intend to write an alternative Terraform setup for hosting on any k8s instance.
Feel free to skip to the repo.
Here's the plan for this post:
- Run everything from one repository
- Manage infrastructure using Terraform
- Store Terraform secrets and secrets in a 1Password vault
- Manage Terraform from Github
Run everything from one repository
First, we'll set up a Git monorepo managed through Just. For this first post, we'll only require a few files and directories:
.
├── .git/...
├── .github/
│ └── workflows/
│ └── infra.yaml
├── infra/
│ ├── justfile
│ ├── main.tf
│ └── terraform.tfvars
└── justfile
In future posts, we'll add things like server/
, web/
, and worker/
, a Cargo.toml
, and presumably many other important files and projects.
Manage infrastructure using Terraform
First, let's add the Github repository itself through Terraform. While this repository is intended as a template[2], it's also going to be an example and testing repo.
To do this, I have to register a new Github personal access token. I'll call it cochrane. For now, I'll give this token full access to everything on the cochrane repository. Let's put that in a terraform.tfvars
file (make sure to add it to the Gitignore!) and call it github_token
. We'll also add a github_owner
for and github_name
.
Then, we add the repository config to our main.tf
:
variable "github_token" {
type = string
description = "Your Github OAuth token"
}
variable "github_owner" {
type = string
description = "The owner of the Github repository"
}
variable "github_name" {
type = string
description = "The name of the Github repository"
}
terraform {
required_providers {
github = {
source = "integrations/github"
version = "~> 5.0"
}
}
}
provider "github" {
token = var.github_token
owner = var.github_owner
}
resource "github_repository" "repository" {
name = var.github_name
visibility = "private"
}
We'll separate this and clean it up when it starts risking getting messy. We'll need to setup the Terraform state:
$ terraform init
$ terraform import github_repository.repository cochrane
And then for future usage we'll add the required commands to our infra/justfile
, so that we can run things like just infra/plan
.
plan:
terraform plan -var-file=terraform.tfvars
apply:
terraform apply -var-file=terraform.tfvars
Store Terraform secrets and state in a 1Password vault
This might be a bit unorthodox, but I don't like using more than one tool to do the same thing, and this entire project is about building something that works for me. I'll be storing the terraform.tfvars
values in 1Password and using its op inject
tool to load it. Additionally, we'll be using op item
and op read -o
to load the terraform.tfstate
file.
Secrets
Let's start with the easy part: secrets. This is obviously a quick op inject
away. First, I've moved the Github token into a new Cochrane vault. Then we'll create a terraform.tfvars.tpl
file with our 1Password secrets referenced:
github_owner = "martijnarts"
github_name = "cochrane"
github_token = "op://cochrane/github/token"
I could now run op inject
directly, but I don't want to worry about running this every time I update my secrets. Instead, let's modify the justfile
[3]!
inject-tfvars:
op inject -i ./terraform.tfvars.tpl -o terraform.tfvars
plan: inject-tfvars
terraform plan -var-file=terraform.tfvars
apply: inject-tfvars
terraform apply -var-file=terraform.tfvars
Now Just will automatically inject the tfvars file whenever we try to plan or apply our changes.
State
Now for the other part: we want to store our Terraform state inside 1Password also. We'll reuse the Cochrane vault and create a new item called terraform_state
with the current terraform.tfstate
file in it:
$ op document create infra/terraform.tfstate --vault cochrane --title "terraform_tfstate"
Now we just need to update the Justfile again:
inject-tfvars:
op inject -i ./terraform.tfvars.tpl -o terraform.tfvars
load-tfstate:
op document get --vault cochrane -o ./terraform.tfstate
store-tfstate:
op document edit --vault cochrane terraform_tfstate ./terraform.tfstate
plan: inject-tfvars load-tfstate
terraform plan -var-file=terraform.tfvars
apply: inject-tfvars load-tfstate && store-tfstate
terraform apply -var-file=terraform.tfvars
This works really nicely, especially Just's &&
syntax for running other tasks before and after any given task.
Manage Terraform from Github
Access 1Password from Github Actions
Now let's try running at least just infra/plan
in Github Actions. We'll use a Service Account, so let's create that first, store it in the vault and add it to the terraform.tfvars.tpl
so we can have Terraform store the secret in the Github repository.
gha_1p_service_account = "op://cochrane/gha_1p_service_account/token"
resource "github_actions_secret" "op_service_account" {
repository = var.github_name
secret_name = "OP_SERVICE_ACCOUNT"
plaintext_value = var.gha_op_service_account
}
resource "github_actions_secret" "op_service_account" {
repository = var.github_name
secret_name = "OP_SERVICE_ACCOUNT"
plaintext_value = var.gha_op_service_account
}
Running just infra/apply
will store the secret appropriately!
Planning in Actions
I'll leave a lot of this as an exercise to the reader, cause there's a lot of code. It's not very complex though. First, we'll need an additional just command to save a plan to a file that we'll read out for a summary:
save-plan filename="tfplan": init-terraform inject-tfvars load-tfstate
terraform plan -var-file=terraform.tfvars -out={{filename}}
Don't you love how simple that turns out with Just?
name: Apply infra changes
permissions:
issues: write
on:
pull_request:
paths:
- "infra/**"
- ".github/workflows/infra.yaml"
jobs:
plan:
runs-on: ubuntu-latest
steps:
# omitting:
# - actions/checkout
# - 1password/install-cli-action
# - extractions/setup-just
# - hashicorp/setup-terraform
# - kishaningithub/setup-tf-summarize
- name: Run Terraform Plan
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT }}
run: just --dotenv-filename .just-env infra/save-plan
- name: summary in table format
working-directory: ./infra
run: |
rm -rf tf-summarize-table-output.md
echo "## tf-summarize output:" > tf-summarize-table-output.md
echo "" >> tf-summarize-table-output.md
terraform show -json tfplan | tf-summarize -md >> tf-summarize-table-output.md
- name: Find Comment
uses: peter-evans/find-comment@v2
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: tf-summarize output
- name: Create comment with terraform plan summary
uses: peter-evans/create-or-update-comment@v2
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body-file: "./infra/tf-summarize-table-output.md"
edit-mode: replace
This works really nicely, giving a brief summary of the full plan in a PR comment, and updating the summary whenever you commit a change.
Theoretically, we might also want to apply with Github Actions. However, I think that's still a bit too risky for now. Maybe when I've put some more thought into it.
Stay tuned for the application
Now that we have the infrastructure set up, we'll want to run a backend and frontend. Next time, we'll set up a really simple Dioxus frontend and Axum backend and deploy them to Fly.io.
You can find Cochrane here.
This is in reference to the Cochrane 04 shuttle from Voyager that broke the transwarp barrier in an altogether absurd episode. ↩︎
In the actual repository, I've also added a
setup
step to the justfile which actually takes you through the creation of the repo and initialisation and importing of the repository. I'm not putting that here, but you can go check it out if you want. It does mean that I have some code in here that might not make sense when setting up for one specific repository. ↩︎Now ideally, we would have this work like Make, where
inject-tfvars
only gets run when the.tpl
file actually got changed. There's an open issue for that on Just, however it doesn't seem to be prioritized currently. It's fast enough anyway 🤷🏻♂️ ↩︎