Years ago, a mentor of mine introduced me to a Ruby-based server automation tool called Capistrano, and I immediately fell in love. Ready to deploy a new release? Rungit push && cap production deploy
, then you’re done. Even better, Capistrano introduced me to what’s colloquially known as “atomic deployments” — checking out a full copy of the codebase and using symlinks to point to the new release for a zero-downtime deployment — which has since been my gold standard for deployment methods.
I continued to use Capistrano for a few years, until I started working on projects (and teams) large enough to justify a proper continuous delivery (CD) tool. Suddenly, building the application locally and pushing up with Capistrano became more complicated; at the same time, services like DeployBot began offering atomic deployments right out of the box, so it was easy to get up and running.
What about services that don’t offer atomic deployments as a default? I recently deployed a Laravel application via Codeship, where atomic deployments to a VPS becomes more complicated; here’s how I approached it:
What is an atomic deployment?
Before we dig into how, it’s helpful to understand why we might want to use atomic deployments for our applications. Generally speaking, atomic deployments offer two benefits over a traditional “pull down anything that’s changed”-type deployment:
- Atomic deployments offer zero (or near-zero) downtime between releases; the “current” symlink isn’t updated until the new release is ready to go live.
- Atomic deployments make it easier to rollback to an earlier release, as the “current” symlink can quickly be updated to point to one of the previous releases.
The structure of an atomic deployment
The specific structure can vary between platform and implementation, but generally speaking an atomic deployment has three components:
- A number of releases, each containing a complete checkout of the built application.
- Some number of shared resources, which are linked to the releases using symlinks.
- A “current” symlink, which acts as [part of] the web root.
For a practical example, let’s take a look at a typical Capistrano-style setup:
1 2 3 4 5 6 7 |
| - releases/ | - 1525148329/ | - 1525148402/ | - shared/ | - storage | - .env | - current -> releases/1525148402 |
In that directory listing, we have two copies of the codebase within the releases/
directory, each living within a directory named after the Unix timestamp of when the release was deployed.
The shared/
directory, meanwhile, contains things that should remain constant between releases — in Laravel’s case, this is typically the storage/
directory and the .env
file.
Finally, the current
symlink points at the latest release (releases/1525148402
). Within our nginx configuration, we would set our application web root to /path/to/app/current/public
, so the configuration is always using the public/
directory of the current release.
Under this model, when a new release is deployed the codebase will be checked out into a new, timestamped directory within releases/
. Next, storage/
and .env
would be symlinked within the new release, and we may run any necessary database migrations. Finally, once the new release is ready, we’ll update the current
symlink target and restart the web server in order for the release to go live.
If it turns out the new release is broken, we can pretty easily update the current
symlink again to point to a previous, known working release. Pretty cool, huh?
Scripting an atomic deployment
Now that we’ve covered some of the benefits of atomic deployments (as well as how they’re physically structured), let’s talk about CD providers; unfortunately, not every tool offers atomic deployments out of the box, which means tools like Codeship and Jenkins (to name a few) may leave you to do a bit of manual scripting. Fear not, friend, for I’ve done the hard work for you!
Generally speaking, continuous integration (CI) and continuous delivery (CD) providers will break builds into two distinct steps:
- Build the application, ensuring that all necessary tests pass (CI)
- Deploy the new release (CD)
Working through the CI phase is a whole topic in itself, but let’s imagine you’ve set up a CI pipeline with the provider of your choice, and now you’re filling out the “when the build has succeeded, what should we do with it?” prompt.
In general, the process is going to look something like this:
- Ensure the application is built in a way that’s production ready; if you were previously including development dependencies for testing, you’ll want to remove those and install only what’s needed on production.
- Create a tarball of the release and transfer it to the production server(s).
- Connect to the production server(s), extract the tarball into your
releases/
directory, create any necessary symlinks, and run any additional steps. - Update the
current
symlink and restart the web server.
For the aforementioned Laravel application on Codeship, my atomic deployment script looks something like this (don’t worry, we’ll break it down):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
#!/usr/bin/env bash # # CD script for performing atomic deployments. # # Usage: # deploy.sh <environment> # # Expected environment variables. If any of these should be modified based on environment, # please assign them to variables within the `case` block. # # - TARGET_SERVER: The remote server we're deploying to. # - TARGET_DIR: The system path of the target directory on the target server. # - TARGET_USER: The SSH user for the deployment. # # shellcheck disable=SC2029 # Print the usage instructions. function print_usage { echo $"Usage: $0 {development|staging|production}" exit 1 } if [ ! $# -eq 1 ]; then echo -e "\\033[0;31mAn environment name must be passed to the script.\\033[0;0m" print_usage fi # Set local variables TIMESTAMP="$(date +"%s")" # Customize the behavior based on the passed environment case "$1" in staging) TARGET_SERVER="$STAGING_SERVER_ADDR" ;; production) TARGET_SERVER="$PRODUCTION_SERVER_ADDR" ;; *) echo -e "\\033[0;31mThe \"$1\" environment is undefined.\\033[0;0m" print_usage esac # Ensure the runner can SSH into the target server. ssh-keyscan "$TARGET_SERVER" >> ~/.ssh/known_hosts # Remove unnecessary components and archive the app composer install --no-dev --optimize-autoloader --no-suggest --prefer-dist --no-ansi --no-interaction --no-progress --no-scripts mkdir -p bootstrap/cache rm -rf .env after.sh aliases ./*.log node_modules phpunit.* storage tests tmp tar --exclude-vcs -czf "${TIMESTAMP}.tgz" -- * # Copy the tarball to the production server. scp "${TIMESTAMP}.tgz" "${TARGET_USER}@${TARGET_SERVER}:${TARGET_DIR}" # Remove the local tarball. rm "${TIMESTAMP}.tgz" # Extract the archive and set up symlinks. The process, in order: # # 1. Move into the TARGET_DIR directory # 2. Create a new directory within releases/ for the new release # 3. Extract the tarball into the new directory # 4. Remove the tarball # 5. Symlink the .env file from shared/ # 6. Symlink the storage/ directory from shared/ # 7. Run any new database migrations # 8. Flatten the Laravel configuration files # 9. Update the current/ symlink to point to the new release # 10. Restart nginx to pick up on the new web root. At this point, the release is live. # Note that this requires the $TARGET_USER to be able to restart nginx without logging # in as root. See https://stackoverflow.com/a/45071759/329911. # 11. Prime the Artisan view cache # 12. Roll off old releases. See https://engineering.growella.com/jenkins-digital-ocean/ ssh "${TARGET_USER}@${TARGET_SERVER}" "cd \"$TARGET_DIR\" \ && echo -en \"Extracting tarball to releases/${TIMESTAMP}...\" \ && mkdir -p \"releases/${TIMESTAMP}\" \ && tar -xzf \"${TIMESTAMP}.tgz\" --directory \"releases/${TIMESTAMP}\" \ && rm \"${TIMESTAMP}.tgz\" \ && echo -e \"\\033[0;32mOK\\033[0;0m\" \ && echo -en \"Symlinking shared resources...\" \ && ln -s \"${TARGET_DIR}/shared/.env\" \"${TARGET_DIR}/releases/${TIMESTAMP}/.env\" \ && ln -s \"${TARGET_DIR}/shared/storage\" \"${TARGET_DIR}/releases/${TIMESTAMP}/storage\" \ && echo -e \"\\033[0;32mOK\\033[0;0m\" \ && php \"releases/${TIMESTAMP}/artisan\" migrate --force \ && php \"releases/${TIMESTAMP}/artisan\" config:cache \ && php \"releases/${TIMESTAMP}/artisan\" view:clear \ && echo -en \"Updating current symlink...\" \ && ln -sfrn \"${TARGET_DIR}/releases/${TIMESTAMP}\" \"${TARGET_DIR}/current\" \ && sudo systemctl reload nginx \ && echo -e \"\\033[0;32mOK\\033[0;0m\" \ && echo -en \"Rolling off old releases...\" \ && cd releases && ls -1 | sort -r | tail -n +6 | xargs rm -rf \ && echo -e \"\\033[0;32mOK\\033[0;0m\" \ && echo \ && echo -e \"\\033[0;32mDeployment completed successfully\\033[0;0m\" \ " |
For this application, I chose to keep the deployment script within the application codebase itself (in bin/deploy.sh
), rather than putting it all in Codeship. It’s a bit of personal preference, but I’d rather the script be versioned with the rest of the app rather than thrown into a <textarea>
in Codeship. Once my CI pipeline passes, I can call sh bin/deploy.sh <environment>
to deploy!
Preparing for atomic deployments
Before we can perform atomic deployments, there are a few things we’ll need to do on our target server(s):
- Create the directory structure
- Create the deployment user w/ SSH key
- Grant the deployment user the ability to reload the web server
The way you go about this will depend on your server environment, but I typically like to run the app under a deploy
(or similar) user who only has access to the app directory (in this example, /var/www/myapp
):
1 |
$ useradd deploy -d /var/www/myapp -M -s /bin/bash |
Once the user has been created, we need to give them the public SSH key that corresponds to the private SSH key used by our CD platform. If the platform doesn’t provide a public key for you, you may need to generate a new SSH key and store the private key in an environment variable in the CD environment.
With the public key in-hand, add it to the deploy
user’s ~/.ssh/authorized_keys
file. This will allow the CD platform to SSH into the server, which we’ll need later.
The last step is to add limited privileges to the deploy user — if we’re updating the document root (via symlink), our web server needs to be reloaded.
1 2 |
# Let the deploy user reload nginx without having full root access echo "deploy ALL=(ALL) NOPASSWD: systemctl reload nginx" >> /etc/sudoers.d/deploy |
This lets the deploy
user run sudo systemctl reload nginx
without granting access to any other commands — this corresponds to step 10 in our deployment script, and should be the last thing we do to make our new version live.
Environment variables
You’ll notice that my script starts with a comment outlining a few necessary environment variables ($TARGET_SERVER
, $TARGET_DIR
, and $TARGET_USER
); these will enable me to change where (and who) the app will be deployed by without hard-coding these values into my deployment script. In the case of the setup work we did in the last section, $TARGET_DIR
and $TARGET_USER
will be /var/www/myapp
and deploy
, respectively, while $TARGET_SERVER
will vary based on the environment we’re deploying to.
I’m also creating the $TIMESTAMP
variable, which captures the Unix timestamp of when I first started running this script. This variable will end up being the directory name of my new release.
Building the archive
Next, my script removes the vendor/
directory (which currently contains development dependencies like PHPUnit), then runs composer install --no-dev
to pull in only what’s needed for production.
The script also removes files and directories that won’t be necessary on production, like tests/
, phpunit.xml.dist
, etc. This step is optional, but it can help reduce the size of the tarball and thus speed up your deployments.
Next, I create an archive of the application, using the $TIMESTAMP
variable to determine the archive name (e.g. 1525148402.tgz
). This tarball will then be copied to the $TARGET_SERVER
via scp
, and our local copy of the tarball removed.
Preparing the release
Now that the release has been copied to the production server(s), we need to do a few things to get it ready to go live:
- Extract the tarball to the
releases/
directory - Symlink any shared resources between the new release and the
shared/
directory; in this case, we’re symlinking thestorage/
directory and.env
file. - Run a few Artisan commands to get the release ready: perform any pending database migrations, refresh the configuration and view caches, etc.
Once the release is ready to go, the last thing we need to do is update the current
symlink to point to the new release, then restart the web server. Assuming everything went smoothly, our new release should be up and running with little-to-no downtime!
Cleaning up after a release
As we (confidently) deploy over and over, with the ability to ship code as soon as it’s ready and with zero-downtime, we’ll quickly build up a library of releases on our server. While it’s great to have a couple releases we can roll back to if need be, we probably don’t need or want the entire release history clogging up our production server(s).
That’s where the last step of the deployment script comes in: after successfully updating the current
symlink, our script will automatically sort the [timestamped] directories in the releases/
directory and keep the five latest (current + 4 previous), removing the rest. Eagle-eyed readers may recall me writing about this over on the Engineering @ Growella blog.
Wrapping Up
Hopefully this has given you a high-level look at how to start using atomic deployments for your applications. Every app is a little different and there are lots of great tools to handle this for you, but it’s entirely possible to deploy atomically using free tools like Travis CI, GitLab’s CI/CD pipelines, and more!
Fairuz WAN ISMAIL
How about doing a rollback of database migrations?
The app can be in a messed up state if we just doing a rollback of the application
Felipe Alvarado
With laravel migrations you can rollback the db as you rollback the env