--- # Rsync-based Deployment Playbook with Releases/Symlink Pattern # Implements production-ready deployment with zero-downtime and rollback support # No GitHub dependency - deploys directly from local machine # # Usage: # ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-rsync-based.yml # ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-rsync-based.yml --extra-vars "release_tag=v1.0.0" - name: Deploy Custom PHP Framework (Rsync-based with Releases) hosts: web_servers become: true vars: # Application configuration app_name: michaelschiemer app_user: deploy app_group: deploy # Deployment paths app_base_path: "/home/{{ app_user }}/{{ app_name }}" releases_path: "{{ app_base_path }}/releases" shared_path: "{{ app_base_path }}/shared" current_path: "{{ app_base_path }}/current" # Local source directory (project root on your machine) local_project_path: "{{ playbook_dir }}/../../.." # Release configuration release_timestamp: "{{ ansible_date_time.epoch }}" # Note: effective_release_tag is set in pre_tasks based on Git tags release_name: "{{ effective_release_tag | default(release_tag | default(release_timestamp)) }}" release_path: "{{ releases_path }}/{{ release_name }}" # Deployment settings keep_releases: 5 composer_install_flags: "--no-dev --optimize-autoloader --no-interaction" # Shared directories and files # Shared directories that need symlinks # NOTE: storage/logs, storage/cache, storage/uploads are handled by Docker volumes shared_dirs: - storage/sessions - public/uploads shared_files: - .env.production # Rsync exclusions rsync_excludes: - .git/ - .github/ - node_modules/ - .env - .env.local - .env.development - storage/ - public/uploads/ - tests/ - .idea/ - .vscode/ - "*.log" - .DS_Store - deployment/ - database.sqlite - "*.cache" - .php-cs-fixer.cache - var/cache/ - var/logs/ - "*.php85/" - src/**/*.php85/ pre_tasks: # Git Tag Detection and Validation - name: Get current Git tag (if release_tag not specified) local_action: module: command cmd: git describe --tags --exact-match chdir: "{{ local_project_path }}" register: git_current_tag become: false ignore_errors: yes when: release_tag is not defined - name: Get current Git commit hash local_action: module: command cmd: git rev-parse --short HEAD chdir: "{{ local_project_path }}" register: git_commit_hash become: false - name: Set release_name from Git tag or timestamp set_fact: effective_release_tag: "{{ release_tag | default(git_current_tag.stdout if (git_current_tag is defined and git_current_tag.rc == 0) else release_timestamp) }}" git_hash: "{{ git_commit_hash.stdout }}" - name: Display deployment information debug: msg: - "==========================================" - "Deployment Information" - "==========================================" - "Release: {{ effective_release_tag }}" - "Git Hash: {{ git_hash }}" - "Source: {{ local_project_path }}" - "Target: {{ ansible_host }}" - "==========================================" - name: Install Composer dependencies locally before deployment local_action: module: command cmd: composer install {{ composer_install_flags }} chdir: "{{ local_project_path }}" become: false - name: Build NPM assets locally before deployment local_action: module: command cmd: npm run build chdir: "{{ local_project_path }}" become: false - name: Check if deployment lock exists stat: path: "{{ app_base_path }}/.deploy.lock" register: deploy_lock - name: Remove stale deployment lock if force flag is set file: path: "{{ app_base_path }}/.deploy.lock" state: absent when: deploy_lock.stat.exists and (force_deploy | default(false)) - name: Fail if deployment is already in progress (without force) fail: msg: "Deployment already in progress. Lock file exists: {{ app_base_path }}/.deploy.lock. Use --extra-vars 'force_deploy=true' to override." when: deploy_lock.stat.exists and not (force_deploy | default(false)) - name: Create deployment lock file: path: "{{ app_base_path }}/.deploy.lock" state: touch owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0644' - name: Log deployment start lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] Deployment started - Release: {{ release_name }} - User: {{ ansible_user_id }}" create: yes owner: "{{ app_user }}" group: "{{ app_group }}" tasks: # ========================================== # 1. Directory Structure Setup # ========================================== - name: Create base application directory file: path: "{{ app_base_path }}" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0755' - name: Create releases directory file: path: "{{ releases_path }}" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0755' - name: Create shared directory file: path: "{{ shared_path }}" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0755' - name: Create shared subdirectories file: path: "{{ shared_path }}/{{ item }}" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0755' loop: "{{ shared_dirs }}" # ========================================== # 2. Rsync Application Code to New Release # ========================================== - name: Remove old release directory if exists (prevent permission issues) file: path: "{{ release_path }}" state: absent - name: Create new release directory file: path: "{{ release_path }}" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0755' - name: Temporarily rename .dockerignore to prevent rsync -F from reading it command: mv {{ local_project_path }}/.dockerignore {{ local_project_path }}/.dockerignore.bak delegate_to: localhost become: false ignore_errors: yes - name: Sync application code to new release via rsync (raw command to avoid -F flag) command: > rsync --delay-updates --compress --delete-after --archive --rsh='ssh -i {{ ansible_ssh_private_key_file }} -o StrictHostKeyChecking=no' --no-g --no-o {% for exclude in rsync_excludes %}--exclude='{{ exclude }}' {% endfor %} {{ local_project_path }}/ {{ app_user }}@{{ ansible_host }}:{{ release_path }}/ delegate_to: localhost become: false - name: Restore .dockerignore after rsync command: mv {{ local_project_path }}/.dockerignore.bak {{ local_project_path }}/.dockerignore delegate_to: localhost become: false ignore_errors: yes - name: Set correct ownership for release file: path: "{{ release_path }}" owner: "{{ app_user }}" group: "{{ app_group }}" recurse: yes - name: Get local git commit hash (if available) command: git rev-parse HEAD args: chdir: "{{ local_project_path }}" register: commit_hash delegate_to: localhost become: false changed_when: false failed_when: false - name: Log release and commit information lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] Release: {{ effective_release_tag }} | Git Hash: {{ git_hash | default('N/A') }} | Commit: {{ commit_hash.stdout | default('N/A') }}" when: commit_hash.rc == 0 # ========================================== # 3. Shared Files/Directories Symlinks # ========================================== - name: Remove shared directories from release (they will be symlinked) file: path: "{{ release_path }}/{{ item }}" state: absent loop: "{{ shared_dirs }}" - name: Create parent directories for symlinks file: path: "{{ release_path }}/{{ item | dirname }}" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0755' loop: "{{ shared_dirs }}" # Skip if dirname is current directory ('.') when: (item | dirname) != '.' - name: Create symlinks for shared directories file: src: "{{ shared_path }}/{{ item }}" dest: "{{ release_path }}/{{ item }}" state: link owner: "{{ app_user }}" group: "{{ app_group }}" force: yes loop: "{{ shared_dirs }}" - name: Remove .env.production from release (will be symlinked) file: path: "{{ release_path }}/.env.production" state: absent - name: Create symlink for .env.production file: src: "{{ shared_path }}/.env.production" dest: "{{ release_path }}/.env.production" state: link owner: "{{ app_user }}" group: "{{ app_group }}" force: yes - name: Create .env symlink with relative path to shared .env.production for Docker container access file: src: "../../shared/.env.production" dest: "{{ release_path }}/.env" state: link owner: "{{ app_user }}" group: "{{ app_group }}" force: yes # ========================================== # 4. Dependencies Installation # ========================================== # Composer dependencies and NPM assets are already built locally and rsync'd # No need to run composer install or npm build on the server # ========================================== # 5. File Permissions # ========================================== - name: Make console script executable file: path: "{{ release_path }}/console.php" mode: '0755' ignore_errors: yes # ========================================== # 6. Database Migrations (Optional) # ========================================== - name: Run database migrations command: php console.php db:migrate --no-interaction args: chdir: "{{ release_path }}" become_user: "{{ app_user }}" when: run_migrations | default(false) | bool register: migrations_result - name: Log migration result lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] Migrations: {{ migrations_result.stdout | default('skipped') }}" when: run_migrations | default(false) | bool # ========================================== # 7. Prepare for Deployment # ========================================== - name: Get current release (before switch) stat: path: "{{ current_path }}" register: current_release_before - name: Stop existing Docker containers (if any) command: docker compose down args: chdir: "{{ current_path }}" become_user: "{{ app_user }}" when: current_release_before.stat.exists ignore_errors: yes # ========================================== # 8. Symlink Switch (Zero-Downtime) # ========================================== - name: Store previous release path for rollback set_fact: previous_release: "{{ current_release_before.stat.lnk_source | default('none') }}" - name: Switch current symlink to new release (atomic operation) file: src: "{{ release_path }}" dest: "{{ current_path }}" state: link owner: "{{ app_user }}" group: "{{ app_group }}" force: yes - name: Log symlink switch lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] Symlink switched: {{ current_path }} -> {{ release_path }}" # ========================================== # 8.5. SSL Certificate Setup # ========================================== - name: Create SSL directory in release file: path: "{{ release_path }}/ssl" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0755' - name: Copy SSL certificates from certbot to release (if they exist) shell: | if docker ps | grep -q certbot; then docker cp certbot:/etc/letsencrypt/archive/michaelschiemer.de/fullchain1.pem {{ release_path }}/ssl/fullchain.pem 2>/dev/null || true docker cp certbot:/etc/letsencrypt/archive/michaelschiemer.de/privkey1.pem {{ release_path }}/ssl/privkey.pem 2>/dev/null || true chown {{ app_user }}:{{ app_group }} {{ release_path }}/ssl/*.pem 2>/dev/null || true fi args: chdir: "{{ current_path }}" ignore_errors: yes # ========================================== # 9. Start Docker Containers # ========================================== - name: Start Docker containers with new release command: docker compose -f docker-compose.yml -f docker-compose.production.yml up -d --build args: chdir: "{{ current_path }}" become_user: "{{ app_user }}" - name: Wait for containers to be ready pause: seconds: 15 # ========================================== # 10. Health Checks # ========================================== - name: Wait for application to be ready pause: seconds: 10 - name: Health check - Nginx ping endpoint (HTTPS) uri: url: "https://{{ ansible_host }}/ping" method: GET return_content: yes status_code: 200 validate_certs: no follow_redirects: none register: health_check retries: 3 delay: 5 until: health_check.status == 200 ignore_errors: yes - name: Log health check result lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] Health check: {{ health_check.status | default('FAILED') }}" - name: Rollback on health check failure block: - name: Stop failed release containers command: docker compose down args: chdir: "{{ current_path }}" become_user: "{{ app_user }}" - name: Switch symlink back to previous release file: src: "{{ previous_release }}" dest: "{{ current_path }}" state: link force: yes when: previous_release != 'none' - name: Start previous release containers command: docker compose -f docker-compose.yml -f docker-compose.production.yml up -d args: chdir: "{{ current_path }}" become_user: "{{ app_user }}" when: previous_release != 'none' - name: Remove failed release file: path: "{{ release_path }}" state: absent - name: Log rollback lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] ROLLBACK: Health check failed, reverted to {{ previous_release }}" - name: Fail deployment fail: msg: "Deployment failed - health check returned {{ health_check.status }}. Rolled back to previous release." when: health_check.status != 200 # ========================================== # 11. Cleanup Old Releases # ========================================== - name: Get list of all releases find: paths: "{{ releases_path }}" file_type: directory register: all_releases - name: Sort releases by creation time set_fact: sorted_releases: "{{ all_releases.files | sort(attribute='ctime', reverse=true) }}" - name: Remove old releases (keep last {{ keep_releases }}) file: path: "{{ item.path }}" state: absent loop: "{{ sorted_releases[keep_releases:] }}" when: sorted_releases | length > keep_releases - name: Log cleanup lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] Cleanup: Kept {{ [sorted_releases | length, keep_releases] | min }} releases, removed {{ [sorted_releases | length - keep_releases, 0] | max }}" post_tasks: - name: Cleanup and logging block: - name: Remove deployment lock file: path: "{{ app_base_path }}/.deploy.lock" state: absent - name: Log deployment completion lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] Deployment completed successfully - Release: {{ release_name }}" - name: Display deployment summary debug: msg: - "==========================================" - "Deployment Summary" - "==========================================" - "Release: {{ release_name }}" - "Commit: {{ commit_hash.stdout | default('N/A') }}" - "Path: {{ release_path }}" - "Current: {{ current_path }}" - "Health Check: {{ health_check.status | default('N/A') }}" - "Previous Release: {{ previous_release }}" - "==========================================" rescue: - name: Remove deployment lock on failure file: path: "{{ app_base_path }}/.deploy.lock" state: absent - name: Log deployment failure lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] DEPLOYMENT FAILED - Release: {{ release_name }}" - name: Fail with error message fail: msg: "Deployment failed. Check {{ app_base_path }}/deploy.log for details."