--- # Git-based Deployment Playbook with Releases/Symlink Pattern (Gitea) # Implements production-ready deployment with zero-downtime and rollback support # Uses Gitea as Git repository server with SSH-based authentication # # Prerequisites: # - SSH deploy key must be placed in deployment/infrastructure/secrets/gitea_deploy_key # - Deploy key must be added to Gitea repository or user account # # Usage: # ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-git-based.yml # ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-git-based.yml --extra-vars "git_branch=main" # ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-git-based.yml --extra-vars "release_tag=v1.0.0" - name: Deploy Custom PHP Framework (Git-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: "/var/www/{{ app_name }}" releases_path: "{{ app_base_path }}/releases" shared_path: "{{ app_base_path }}/shared" current_path: "{{ app_base_path }}/current" # Git configuration (Gitea) # Use localhost for local testing, git.michaelschiemer.de for production git_repo: "git@localhost:michael/michaelschiemer.git" git_branch: "{{ release_tag | default('main') }}" git_ssh_key: "/home/{{ app_user }}/.ssh/gitea_deploy_key" # Release configuration release_timestamp: "{{ ansible_date_time.epoch }}" release_name: "{{ 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_dirs: - storage/logs - storage/cache - storage/sessions - storage/uploads - public/uploads shared_files: - .env.production tasks: # ========================================== # 1. SSH Key Setup for Gitea Access # ========================================== - name: Create .ssh directory for deploy user file: path: "/home/{{ app_user }}/.ssh" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0700' - name: Copy Gitea deploy SSH private key copy: src: "{{ playbook_dir }}/../secrets/gitea_deploy_key" dest: "{{ git_ssh_key }}" owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0600' - name: Copy Gitea deploy SSH public key copy: src: "{{ playbook_dir }}/../secrets/gitea_deploy_key.pub" dest: "{{ git_ssh_key }}.pub" owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0644' - name: Configure SSH for Gitea (disable StrictHostKeyChecking) blockinfile: path: "/home/{{ app_user }}/.ssh/config" create: yes owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0600' marker: "# {mark} ANSIBLE MANAGED BLOCK - Gitea SSH Config" block: | Host localhost HostName localhost Port 2222 User git IdentityFile {{ git_ssh_key }} StrictHostKeyChecking no UserKnownHostsFile /dev/null Host git.michaelschiemer.de HostName git.michaelschiemer.de Port 2222 User git IdentityFile {{ git_ssh_key }} StrictHostKeyChecking no UserKnownHostsFile /dev/null # ========================================== # 2. Directory Structure Setup # ========================================== - name: Create base application directory file: path: "{{ app_base_path }}" state: directory owner: "{{ app_user }}" group: "{{ app_group }}" mode: '0755' - name: Check if deployment lock exists stat: path: "{{ app_base_path }}/.deploy.lock" register: deploy_lock - name: Fail if deployment is already in progress fail: msg: "Deployment already in progress. Lock file exists: {{ app_base_path }}/.deploy.lock" when: deploy_lock.stat.exists - 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 }}" - 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. Git Repository Clone # ========================================== - name: Clone repository to new release directory git: repo: "{{ git_repo }}" dest: "{{ release_path }}" version: "{{ git_branch }}" force: yes depth: 1 become_user: "{{ app_user }}" register: git_clone - name: Get current commit hash command: git rev-parse HEAD args: chdir: "{{ release_path }}" register: commit_hash changed_when: false - name: Log commit hash lineinfile: path: "{{ app_base_path }}/deploy.log" line: "[{{ ansible_date_time.iso8601 }}] Commit: {{ commit_hash.stdout }}" # ========================================== # 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 symlinks for shared directories file: src: "{{ shared_path }}/{{ item }}" dest: "{{ release_path }}/{{ item }}" state: link owner: "{{ app_user }}" group: "{{ app_group }}" loop: "{{ shared_dirs }}" - name: Create symlinks for shared files file: src: "{{ shared_path }}/{{ item }}" dest: "{{ release_path }}/{{ item }}" state: link owner: "{{ app_user }}" group: "{{ app_group }}" loop: "{{ shared_files }}" when: shared_files | length > 0 # ========================================== # 4. Dependencies Installation # ========================================== - name: Install Composer dependencies composer: command: install arguments: "{{ composer_install_flags }}" working_dir: "{{ release_path }}" become_user: "{{ app_user }}" environment: COMPOSER_HOME: "/home/{{ app_user }}/.composer" - name: Check if package.json exists stat: path: "{{ release_path }}/package.json" register: package_json - name: Install NPM dependencies and build assets block: - name: Install NPM dependencies npm: path: "{{ release_path }}" state: present production: yes become_user: "{{ app_user }}" - name: Build production assets command: npm run build args: chdir: "{{ release_path }}" become_user: "{{ app_user }}" when: package_json.stat.exists # ========================================== # 5. File Permissions # ========================================== - name: Set correct ownership for release file: path: "{{ release_path }}" owner: "{{ app_user }}" group: "{{ app_group }}" recurse: yes - 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. Symlink Switch (Zero-Downtime) # ========================================== - name: Get current release (before switch) stat: path: "{{ current_path }}" register: current_release_before - 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. Health Checks # ========================================== - name: Wait for application to be ready wait_for: timeout: 10 delegate_to: localhost - name: Health check - Summary endpoint uri: url: "http://{{ ansible_host }}/health/summary" method: GET return_content: yes status_code: 200 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: Switch symlink back to previous release file: src: "{{ previous_release }}" dest: "{{ current_path }}" state: link force: yes 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 # ========================================== # 9. 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 }}" - "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."