No more manual config
I want to run my droplet with my blog and all my games and silly one-off web apps without ever touching it manually. This would allow me to destroy and rebuild it on a whim, using config as code, CI/CD, etc..
I recently messed around with user access and locked myself out of my droplet. Around the same time, my draft blog stopped updating properly. The update failure is likely a file ownership problem. The cron jobs are owned by root, but the scripts are now located elsewhere and are owned by my user. Also, one of my silly web apps, 13thagegen, is now throwing a 502 Bad Gateway, probably because I messed with the permissions on the nginx dirs.
I’m going to use terraform, ansible, and bash to get this sorted. Then hopefully I’ll never have to touch the droplet again, and the droplet itself won’t matter; I’d just spin up a new one and update the DNS.
Terraform basics
Terraform wraps many web resource provisioning APIs with a common interface that can be interacted with through JSON or HCL (Hashicorp Configuration Language). It’s config as code, which means aspects of the web resource are described in code, and terraform takes care of interacting with the target platform and setting everything up. This way there is never any doubt about the state of the system, and new systems can be created with the exact same characteristics.
- Official DigitalOcean Terraform Docs
- applying a terraform config using a DigitalOcean token and ssh key
|
|
- destroying an existing resource (key may not be necessary)
|
|
Ansible basics
Ansible’s main feature is its capacity for idempotence. Idempotence just means something can be executed an unlimited number of times, and the end result will be the same.
Another key feature is jinja templating. Ansible variables can be referenced in any of ansible’s inventory, config, and task files by using jinja templating. This means only one nginx template is needed for each type of app, because the parts that change (ports, paths, subdomains) can just swapped out for a variable. Adding a new app of an existing type is as simple as adding an entry to the content dictionary.
Running scripts
All ansible executions are handled with bash wrappers to guarantee environment stability and repeatability.
Modules used
- apt
- authorized_key
- cron
- copy
- file
- git
- service
- shell
- template
- uri
- user
Building and connecting to the droplet
Terraform config
The terraform config describes what the fresh droplet should look like.
The following configuration uses a DO provider, droplet resource, file provisioner, output, DO domain resource, and DO record resource.
web.tf
|
|
Ubuntu was chosen solely because the original manually-configured droplet used Ubuntu.
There are two input variables, do_token
and pvt_key
.
do_token
gives terraform access to change my DigitalOcean resources.- This token can be generated here: https://cloud.digitalocean.com/account/api/tokens?i=fe3204
pvt_key
is the ssh key to be used to connect to the droplet after it’s created and create an installation script.- This key needs to be generated on your workstation and added here: https://cloud.digitalocean.com/account/security
- It’s used by telling terraform to read the file contents as follows.
|
|
The ssh_keys
value is just the MD5 of the public key that matches the pvt_key
. The MD5 can be determined with: ssh-keygen -E md5 -lf /path/to/your/ssh/key
. The result is the same whether targeting the public or private key with this command. If you’re using an ssh key that is not the default name (~/.ssh/id_rsa
), you may also need to add the key identity to the ssh-agent: ssh-add ~/path/to/private/key
.
Terraform will export several variables during execution, and these variables can be used in the terraform config. Here I’ve used the ip4v address variable host = "${self.ipv4_address}"
to immediately connect to the droplet and create the script we’ll need to run before ansible will be able to connect to the droplet.
The file provisioner is used to copy a local script to the droplet. The command contained in this script is required for ansible to function (it installs python). This file copy could also easily be handled by an scp command, but terraform is a more ‘config as code’ approach, as it’s declarative rather than scripted. Conversely, instead of running the script with ssh, terraform could run it directly on pod creation.
Terraform’s output block can capture any terraform variables and export them for use by other scripts. This automatically creates a terraform_output
file which contains a JSON representation of the exported variables. Later, this file is read to automatically determine the IP address and make it available to the rest of the scripts.
Finally, a DNS record is created with a CNAME using digitalocean_domain
and digitalocean_record
so my domain automatically gets applied to the new droplet. TTL is defaulted to 1800s (30 minutes) and there does not appear to be an option to change this value. Since this is a personal droplet, the downtime is perfectly fine.
Ansible plays
The rest of the heavy lifting is done by ansible.
Ansible does several things for my droplet:
- sets up a non-root user account, including necessary keys and passwords to connect to the droplet, and to clone from gitlab
- installs system packages with apt and uri
- hugo for compiling static blog files
- nginx for serving apps and web pages
- pipenv for running flask apps
- copies content and sets up update scripts and cron jobs for that content
Here’s a play that verifies ansible can be used to connect to the droplet, ensuring the proper keys and packages are in place to run user configuration, utility setup, and application deploy plays.
|
|
The commands
The various commands are packaged into shell scripts, and those scripts are invoked through a wrapper script to ensure the droplet is set up the same way every time.
The organizational strategy is:
- terraform - destroy the droplet
- terraform - create the droplet
- ssh - test the connection to ensure access is correctly configured
- ssh - (this is the last time ssh is used by itself; complex system changes beyond initial creation and connection are all handled by ansible) install base packages - in this case just python (thanks for not including it Ubuntu)
- ansible - verify the ansible connection (anything beyond this point is 100% ansible)
- ansible - set up user access
- ansible - deploy applications and content
recreate.sh
|
|
run_terraform.sh
|
|
test_connection.sh
|
|
install.sh
|
|
test_ansible.sh
|
|
Adding a non-root user
Generally, a remote host should be accessed with a non-root user for reasons. This ansible play will add the user, with the hashed password provided in an extra variable.
configure_access.sh
|
|
The password will need to be stored locally; The task uses an environment variable, but there are other approaches. The shell is set to bash
because the default with Ubuntu is sh
and nobody wants that on purpose. The UID can be anything over 1000.
|
|
configure_access.yml
|
|
This should allow passwordless connection via ssh, elevation to sudo using the provided REMOTE_PASS
, and cloning from gitlab.
Serving content
The necessasry components to be able to serve content are nginx, pipenv, hugo, and the content files.
deploy.sh
invokes the deploy.yml
ansible play, which is set up as a role. Roles make it easy to manage variables and templates, since ansible assumes anything inside the roles/$target_role
directory belongs to that role, and will look for directories named defaults
, templates
, and plays
(among others). Ansible will load main.yml
from within these directories if found.
Configuring nginx
Nginx config is set up by ansible.
Installation is done with apt
. The default site is removed (file: state=absent
) and the default nginx.conf
is updated to a custom configuration (template
). Then the service is restarted. If the restart fails, it means there’s a problem with the config, and the play will stop.
nginx.yml
|
|
Installing Hugo
Hugo is a markdown to static file compiler. It allows me to write blog posts in markdown, then at deploy time compiles those files into shiny HTML/CSS.
hugo.yml
|
|
The content play deploys the files nginx will serve and sets up sites-available
and sites-enabled
.
There are 4 main types of sites served:
- (many) static html files served from subdomains
- (many) flask applications
- (unique) landing page (no subdomain)
- (unique) a catch-all that redirects to the landing page
Installing Pipenv
I’m a bit ashamed of this play. It downloads a script and runs it (scary) to install pipenv. I’ll address this later.
Pipenv is used to run python applications. I’ve used it to run my flask apps. The key feature of pipenv is that it uses actual shell sessions with updated paths instead of faking the paths in the current session the way virtualenv does. Whether this is better I still don’t know. I know I’m annoyed every time deactivate removes the (venv is active) indicator, but non-obviously leaves the pipenv shell
session open, which functions just fine, until the next time pipenv shell
is ran and fails with a cryptic error.
Still on the fence about pipenv.
pipenv.yml
|
|
Updating apps and blog content automatically
The first pass at automating my droplet was to get my blog content to update automatically when content was pushed to the blog repo. A bash script with some directory syncing trickery and some trap commands to handle rollbacks was used. It was converted and enhanced from a sample on the web. A no-op check was added so that when there were no git repo changes, unnecessary file operations would be avoided. There’s also a hack-job of a logging system that dumps any messages to a maint.log
file, which truncates itself when it gets too long.
It would be possible to handle the same content management approach using ansible, but since I don’t have a deploy server, I’d have to run ansible on the droplet itself. This would be fine, but bash works just as well. The script was adapted for all types of content. This script is called by scripts that have custom logic for the various types of content (hugo compile, pipenv install, etc.). Here’s the script.
To deploy the update script, ansible uses templating. A cron job is added (also ansible) to run it on a timer.
|
|
Finally, ansible drops the app configs for nginx (more templates) into sites-available
, symlinks them to sites-enabled
, and restarts nginx.
|
|
Adding new content
It’s really easy to add new content with this setup.
For example, a new flask app just requires this to be added to roles/deploy/defaults/main.yml
in the content dictionary:
|
|
The only thing that varies between other flask apps are the name, ports, subdomain, and repo. It uses the exact same templates for updates and nginx as other flask app.
Here’s the template for a flask app:
|
|
The server_name entries could be converted into a list in the config, which would allow the other three types (static, landing, and default pages) to be combined into one template.
Static content is added the same way. Most of my static content is HTML5 games and hugo blogs. The only thing in the template that varies is the name, port, subdomain, and repo. Their config looks like this:
|
|
This is the static content nginx template:
|
|