Skip to main content
Back to Lab
infrastructure

Releases, Not git pull: A Tiny Ansible Template for Atomic Deploys

A minimal, MIT-licensed Ansible template for atomic deploys: every release in its own numbered folder, shared files linked in, one atomic symlink switch to go live, a health check that rolls back automatically on failure, one-command manual rollback, and pruning of old releases.

Date: November 5, 2026
Read Time: 7 min
Tags:
ansibledevopsdeploymentrollbackzero-downtimeopen-sourceinfrastructure

Releases, not git pull: four release folders, a "current" symlink pointing at the newest one, and a dashed rollback arrow

Most small teams start deploying the same way: SSH in, git pull, rebuild, restart. It works, right up to the day a build fails halfway and the site is broken, or a visitor lands on a half-updated page, or it is 2 a.m. and you need yesterday's version back and "yesterday's version" means another pull and another full build.

There is an older, calmer pattern that fixes all of that, and it fits in a few small Ansible files: put every deploy in its own numbered folder, and make one symlink point at the live one.

This post walks through a minimal version of it. The template is open source, MIT licensed, and short enough to read in one sitting, so you can bend it to your own stack.

Why not just deploy in place?

Two cards comparing deploying in place with git pull against numbered releases

Deploying in place mutates the code your visitors are using, while they are using it. Numbered releases flip that around: the new version is built and checked next to the live one, and "going live" is a single, instant switch.

The whole idea in one picture

A web server follows the current symlink to release 5 inside the releases folder; release 5 links to .env and storage in the shared folder

On the server it looks like this:

/srv/myapp/ ├── current -> releases/5 what nginx or your service points at ├── releases/ │ ├── 3/ │ ├── 4/ │ └── 5/ live └── shared/ ├── .env survives deploys └── storage/ uploads, caches, anything stateful

Three rules make it work:

  1. A release is never edited after it goes live. To change something, make a new release.
  2. Anything that must survive a deploy lives in shared/ and is linked into each release.
  3. The only thing that changes at go-live is where current points.

Your web server (or service) always uses /srv/myapp/current/..., so it never needs to know which release is live.

One deploy, step by step

Flow chart: build, unpack, link shared files, prepare, switch current, restart and check, then either prune and finish or roll back and fail with a clear message

The role is plain Ansible. Here are the interesting parts.

1. Pick the next release number

Release numbers are just the highest existing folder plus one. They are never reused, even after a failed deploy.

- name: List the existing releases ansible.builtin.find: paths: "{{ base_dir }}/releases" file_type: directory patterns: '^[0-9]+$' use_regex: true register: existing - name: Pick the next release number (1, 2, 3 ...) ansible.builtin.set_fact: release: "{{ ((existing.files | map(attribute='path') | map('basename') | map('int') | list) + [0]) | max + 1 }}"

2. Unpack, link, prepare, all before anything is live

The artifact is built somewhere else (your CI, your laptop) and shipped as a tarball or zip. Shared files are linked in, and optional commands (install dependencies, warm caches) run inside the new folder.

- name: Unpack the artifact ansible.builtin.unarchive: src: "{{ artifact }}" dest: "{{ release_dir }}" - name: Link the shared files ansible.builtin.file: src: "{{ base_dir }}/shared/{{ item }}" dest: "{{ release_dir }}/{{ item }}" state: link force: true loop: "{{ shared_files }}"

If a shared file is missing, the role stops before touching anything and tells you which one to create. A broken deploy that never started is better than a half-finished one.

3. The switch

- name: Switch current to the new release ansible.builtin.file: src: "{{ release_dir }}" dest: "{{ base_dir }}/current" state: link force: true

This one task is the whole "go live". Ansible's file module replaces an existing symlink by creating a temporary link and renaming it over the old one, and a rename is atomic: a request sees the old release or the new release, never something in between.

4. Check it, and undo it if it is not healthy

This is the part that lets you deploy with less worry. The switch, restart and health check sit in a block, and a rescue puts the old release back if anything in there fails:

- name: Go live, and undo it if the health check fails block: - name: Switch current to the new release # ... as above ... - name: Restart and check ansible.builtin.include_tasks: restart_and_check.yml rescue: - name: Point current back at the previous release ansible.builtin.file: src: "{{ previous }}" dest: "{{ base_dir }}/current" state: link force: true when: previous | length > 0 - name: Restart on the previous release ansible.builtin.shell: "{{ restart_command }}" when: previous | length > 0 and restart_command | length > 0 - name: Stop here ansible.builtin.fail: msg: "Release {{ release }} failed. Rolled back to {{ previous | basename }}."

The failed release stays on disk, so you can look at exactly what was deployed. We tested this by pointing the health check at a port nothing listens on; the result was a failed run, the old release live again, and the service restarted on it (message shortened here):

Release 7 failed (Status code was -1 and not [200]: Request failed: Connection refused). Rolled back to 6. The failed release stays in /srv/myapp/releases/7 for debugging.

5. Prune

After a successful deploy the role keeps the newest keep_releases folders (five by default), never the live one, and removes the rest.

Rolling back by hand

Sometimes the new release passes its health check and is still wrong: a bad config, a regression nobody tested. Rolling back is the same operation in reverse, and it takes seconds because the old release is still on disk and needs no build:

ansible-playbook rollback.yml -e app=myapp # previous release ansible-playbook rollback.yml -e app=myapp -e to=4 # a specific one
localhost: rolled back from release 6 to 5

Asking for a release that does not exist fails with a clear message and changes nothing.

Using the template

cp inventory.example.yml inventory.yml # your servers, in a group called "web" cp vars/example.yml vars/myapp.yml # paths, shared files, restart command, health URL # once per server: create /srv/myapp/shared/.env ansible-playbook deploy.yml -e app=myapp -e artifact=./dist/myapp.tar.gz

Everything about your app lives in one small vars file:

base_dir: /srv/myapp shared_files: [".env"] shared_dirs: ["storage"] release_commands: ["npm ci --omit=dev"] restart_command: "systemctl reload myapp" health_url: "http://127.0.0.1:8080/health" keep_releases: 5

Useful flags: --limit web1 deploys to one server first (a canary), -e serial=2 rolls through two at a time, and --check is a dry run.

Gotchas we ran into

  • Long-running processes keep the old folder. A Node, Python or Ruby process started from releases/4 keeps running from releases/4 after the symlink moves. A static site needs no restart; anything else needs a reload or restart, which is what restart_command is for.
  • FPM and opcache. In nginx, pass $realpath_root (not $document_root) as SCRIPT_FILENAME, so opcache keys files by the real release path. Otherwise PHP can keep serving cached code from the previous release.
  • Migrations are not part of the symlink. Run them once rather than once per server, and keep them backward compatible (add first, remove later), so that rolling the code back never meets a schema it does not understand.
  • Secrets stay out of the artifact. Keep them in shared/ (managed with Ansible Vault or your secret store of choice) and link them in.
  • A shell command is not a security boundary. restart_command and release_commands run as written. They come from your own vars file, so treat that file like code and review changes to it.
  • A failed deploy leaves a folder behind. That is on purpose, since it is your evidence. Pruning cleans it up later.

Where to take it next

This template deliberately does very little, which is why it is easy to trust. Natural next steps:

  • Build the artifact in CI and call the playbook from there.
  • Add a notification (chat message or webhook) at the end of a deploy and on rollback.
  • Write a RELEASE.json into each release with the commit and time, so you can always tell what is live.
  • Make the health check smarter than "answers 200": hit a page that touches the database and the cache.
  • Add a second step to the rolling deploy that waits for the load balancer to see the server healthy again.

Get it

The template is MIT licensed: github.com/Raspiska-Ltd/ansible-release-template. Clone it, point it at a test server first, and make the first deploy something harmless. We would love to hear what you change.

Technologies Used

DevOps & Cloud

Ansible

Other

Linuxnginx

Have a project in mind?

Let's work together to bring your ideas to life. Our team of experts is ready to help you build something amazing.