fix: staging deployment configuration and redis secrets handling
This commit is contained in:
250
deployment/ansible/playbooks/debug-staging-redis-secrets.yml
Normal file
250
deployment/ansible/playbooks/debug-staging-redis-secrets.yml
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
- name: Debug Staging Redis Secrets Configuration
|
||||
hosts: production
|
||||
gather_facts: yes
|
||||
become: no
|
||||
|
||||
tasks:
|
||||
- name: Check staging stack directory
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
echo "=== Staging Stack Directory ==="
|
||||
pwd
|
||||
ls -la
|
||||
register: dir_check
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display directory contents
|
||||
debug:
|
||||
msg: "{{ dir_check.stdout_lines }}"
|
||||
|
||||
- name: Check if docker-compose files exist
|
||||
stat:
|
||||
path: "{{ item }}"
|
||||
vars:
|
||||
deployment_path: "~/deployment/stacks/staging"
|
||||
with_items:
|
||||
- "{{ deployment_path }}/docker-compose.base.yml"
|
||||
- "{{ deployment_path }}/docker-compose.staging.yml"
|
||||
register: compose_files
|
||||
|
||||
- name: Display compose file status
|
||||
debug:
|
||||
msg: "{{ item.stat.exists | default(false) }}: {{ item.item }}"
|
||||
with_items: "{{ compose_files.results }}"
|
||||
|
||||
- name: Check docker-compose.staging.yml configuration
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
echo "=== Checking docker-compose.staging.yml for Redis secrets ==="
|
||||
if [ -f docker-compose.staging.yml ]; then
|
||||
echo "--- REDIS_PASSWORD_FILE in environment ---"
|
||||
grep -A 5 "staging-app:" docker-compose.staging.yml | grep -A 10 "environment:" | grep "REDIS_PASSWORD_FILE" || echo "REDIS_PASSWORD_FILE not found in staging-app environment"
|
||||
echo ""
|
||||
echo "--- Secrets section for staging-app ---"
|
||||
grep -A 10 "staging-app:" docker-compose.staging.yml | grep -A 15 "secrets:" | head -10 || echo "Secrets section not found"
|
||||
echo ""
|
||||
echo "--- Secrets definitions at bottom ---"
|
||||
tail -30 docker-compose.staging.yml | grep -A 5 "redis_password:" || echo "redis_password secret definition not found"
|
||||
else
|
||||
echo "docker-compose.staging.yml NOT FOUND"
|
||||
fi
|
||||
register: compose_config
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display compose configuration
|
||||
debug:
|
||||
msg: "{{ compose_config.stdout_lines }}"
|
||||
|
||||
- name: Check if secrets directory and files exist
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
echo "=== Secrets Directory ==="
|
||||
if [ -d secrets ]; then
|
||||
echo "secrets/ directory exists"
|
||||
ls -la secrets/
|
||||
echo ""
|
||||
echo "--- redis_password.txt content check ---"
|
||||
if [ -f secrets/redis_password.txt ]; then
|
||||
echo "secrets/redis_password.txt exists"
|
||||
FILE_SIZE=$(stat -f%z secrets/redis_password.txt 2>/dev/null || stat -c%s secrets/redis_password.txt 2>/dev/null || echo "unknown")
|
||||
CONTENT_LENGTH=$(wc -c < secrets/redis_password.txt | tr -d ' ')
|
||||
echo "File size: $FILE_SIZE bytes"
|
||||
echo "Content length: $CONTENT_LENGTH bytes"
|
||||
# Show first 5 chars (for debugging)
|
||||
FIRST_CHARS=$(head -c 5 secrets/redis_password.txt)
|
||||
echo "First 5 chars: $FIRST_CHARS"
|
||||
else
|
||||
echo "secrets/redis_password.txt NOT FOUND"
|
||||
fi
|
||||
else
|
||||
echo "secrets/ directory DOES NOT EXIST"
|
||||
fi
|
||||
register: secrets_check
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display secrets check
|
||||
debug:
|
||||
msg: "{{ secrets_check.stdout_lines }}"
|
||||
|
||||
- name: Check if staging-app container is running
|
||||
shell: |
|
||||
docker ps --filter "name=staging-app" --format "{{.Names}}\t{{.Status}}\t{{.Image}}"
|
||||
register: container_status
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display container status
|
||||
debug:
|
||||
msg: "{{ container_status.stdout_lines }}"
|
||||
|
||||
- name: Check Docker secrets mounted in staging-app container
|
||||
shell: |
|
||||
echo "=== Docker Secrets in staging-app Container ==="
|
||||
if docker ps --filter "name=staging-app" --format "{{.Names}}" | grep -q staging-app; then
|
||||
echo "--- Checking /run/secrets/ directory ---"
|
||||
docker exec staging-app ls -la /run/secrets/ 2>&1 || echo "Cannot access /run/secrets/"
|
||||
echo ""
|
||||
echo "--- Checking redis_password secret file ---"
|
||||
docker exec staging-app cat /run/secrets/redis_password 2>&1 | head -c 20 || echo "redis_password secret NOT FOUND or NOT READABLE"
|
||||
echo "..."
|
||||
echo ""
|
||||
echo "--- File exists check ---"
|
||||
docker exec staging-app test -f /run/secrets/redis_password && echo "redis_password file EXISTS" || echo "redis_password file DOES NOT EXIST"
|
||||
docker exec staging-app test -r /run/secrets/redis_password && echo "redis_password file is READABLE" || echo "redis_password file is NOT READABLE"
|
||||
else
|
||||
echo "staging-app container is NOT RUNNING"
|
||||
fi
|
||||
register: secrets_mounted
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display secrets mount status
|
||||
debug:
|
||||
msg: "{{ secrets_mounted.stdout_lines }}"
|
||||
|
||||
- name: Check Environment Variables in staging-app container
|
||||
shell: |
|
||||
echo "=== Environment Variables in staging-app ==="
|
||||
if docker ps --filter "name=staging-app" --format "{{.Names}}" | grep -q staging-app; then
|
||||
echo "--- Redis-related environment variables ---"
|
||||
docker exec staging-app env | grep -E "(REDIS_|CACHE_|SESSION_|QUEUE_)" || echo "No Redis env vars found"
|
||||
echo ""
|
||||
echo "--- *_FILE environment variables ---"
|
||||
docker exec staging-app env | grep "_FILE" || echo "No _FILE env vars found"
|
||||
echo ""
|
||||
echo "--- All environment variables (first 50) ---"
|
||||
docker exec staging-app env | sort | head -50 || echo "Cannot read environment"
|
||||
else
|
||||
echo "Container not running"
|
||||
fi
|
||||
register: env_vars
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display environment variables
|
||||
debug:
|
||||
msg: "{{ env_vars.stdout_lines }}"
|
||||
|
||||
- name: Test PHP environment resolution (check DockerSecretsResolver)
|
||||
shell: |
|
||||
echo "=== Testing PHP Environment Resolution ==="
|
||||
docker exec staging-app php -r "
|
||||
// Simulate what the Framework does
|
||||
echo '=== System Environment Check ===' . PHP_EOL;
|
||||
echo 'getenv(\"REDIS_PASSWORD_FILE\"): ' . (getenv('REDIS_PASSWORD_FILE') ?: 'NOT SET') . PHP_EOL;
|
||||
echo 'getenv(\"REDIS_PASSWORD\"): ' . (getenv('REDIS_PASSWORD') ? 'SET (length: ' . strlen(getenv('REDIS_PASSWORD')) . ')' : 'NOT SET') . PHP_EOL;
|
||||
echo PHP_EOL;
|
||||
|
||||
echo '=== $_ENV Check ===' . PHP_EOL;
|
||||
echo 'isset($_ENV[\"REDIS_PASSWORD_FILE\"]): ' . (isset(\$_ENV['REDIS_PASSWORD_FILE']) ? 'YES: ' . \$_ENV['REDIS_PASSWORD_FILE'] : 'NO') . PHP_EOL;
|
||||
echo 'isset($_ENV[\"REDIS_PASSWORD\"]): ' . (isset(\$_ENV['REDIS_PASSWORD']) ? 'YES (length: ' . strlen(\$_ENV['REDIS_PASSWORD']) . ')' : 'NO') . PHP_EOL;
|
||||
echo PHP_EOL;
|
||||
|
||||
echo '=== $_SERVER Check ===' . PHP_EOL;
|
||||
echo 'isset($_SERVER[\"REDIS_PASSWORD_FILE\"]): ' . (isset(\$_SERVER['REDIS_PASSWORD_FILE']) ? 'YES: ' . \$_SERVER['REDIS_PASSWORD_FILE'] : 'NO') . PHP_EOL;
|
||||
echo 'isset($_SERVER[\"REDIS_PASSWORD\"]): ' . (isset(\$_SERVER['REDIS_PASSWORD']) ? 'YES (length: ' . strlen(\$_SERVER['REDIS_PASSWORD']) . ')' : 'NO') . PHP_EOL;
|
||||
echo PHP_EOL;
|
||||
|
||||
echo '=== Docker Secrets File Check ===' . PHP_EOL;
|
||||
\$secret_file = '/run/secrets/redis_password';
|
||||
echo 'File path: ' . \$secret_file . PHP_EOL;
|
||||
echo 'File exists: ' . (file_exists(\$secret_file) ? 'YES' : 'NO') . PHP_EOL;
|
||||
if (file_exists(\$secret_file)) {
|
||||
echo 'File readable: ' . (is_readable(\$secret_file) ? 'YES' : 'NO') . PHP_EOL;
|
||||
\$content = file_get_contents(\$secret_file);
|
||||
if (\$content !== false) {
|
||||
echo 'File content length: ' . strlen(trim(\$content)) . PHP_EOL;
|
||||
echo 'File content (first 10 chars): ' . substr(trim(\$content), 0, 10) . '...' . PHP_EOL;
|
||||
} else {
|
||||
echo 'File content: COULD NOT READ' . PHP_EOL;
|
||||
}
|
||||
}
|
||||
echo PHP_EOL;
|
||||
|
||||
// Test DockerSecretsResolver logic
|
||||
echo '=== DockerSecretsResolver Simulation ===' . PHP_EOL;
|
||||
\$variables = getenv();
|
||||
\$file_key = 'REDIS_PASSWORD_FILE';
|
||||
if (isset(\$variables[\$file_key])) {
|
||||
\$file_path = \$variables[\$file_key];
|
||||
echo 'REDIS_PASSWORD_FILE found: ' . \$file_path . PHP_EOL;
|
||||
if (file_exists(\$file_path) && is_readable(\$file_path)) {
|
||||
\$secret_value = trim(file_get_contents(\$file_path));
|
||||
echo 'Secret resolved: YES (length: ' . strlen(\$secret_value) . ')' . PHP_EOL;
|
||||
echo 'Secret value (first 10 chars): ' . substr(\$secret_value, 0, 10) . '...' . PHP_EOL;
|
||||
} else {
|
||||
echo 'Secret resolved: NO (file not accessible)' . PHP_EOL;
|
||||
}
|
||||
} else {
|
||||
echo 'REDIS_PASSWORD_FILE NOT FOUND in environment' . PHP_EOL;
|
||||
}
|
||||
" 2>&1
|
||||
register: php_test
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display PHP environment test
|
||||
debug:
|
||||
msg: "{{ php_test.stdout_lines }}"
|
||||
|
||||
- name: Check staging-redis container configuration
|
||||
shell: |
|
||||
echo "=== Staging Redis Container ==="
|
||||
docker ps --filter "name=staging-redis" --format "{{.Names}}\t{{.Status}}"
|
||||
echo ""
|
||||
echo "=== Redis password requirement ==="
|
||||
docker exec staging-redis redis-cli CONFIG GET requirepass 2>&1 || echo "Cannot check Redis config"
|
||||
echo ""
|
||||
echo "=== Test Redis connection without password ==="
|
||||
docker exec staging-redis redis-cli PING 2>&1 || echo "Connection failed (password required)"
|
||||
register: redis_config
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display Redis configuration
|
||||
debug:
|
||||
msg: "{{ redis_config.stdout_lines }}"
|
||||
|
||||
- name: Check recent staging-app logs for Redis errors
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
echo "=== Recent staging-app logs (Redis-related) ==="
|
||||
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml logs staging-app --tail=100 2>&1 | grep -i -E "(redis|password|secret|auth|noauth)" | tail -30 || echo "No Redis-related logs found"
|
||||
register: app_logs
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display application logs
|
||||
debug:
|
||||
msg: "{{ app_logs.stdout_lines }}"
|
||||
|
||||
- name: Summary and Recommendations
|
||||
debug:
|
||||
msg:
|
||||
- "========================================"
|
||||
- "DEBUG SUMMARY"
|
||||
- "========================================"
|
||||
- "Check the output above for:"
|
||||
- "1. docker-compose.staging.yml has REDIS_PASSWORD_FILE=/run/secrets/redis_password"
|
||||
- "2. secrets/redis_password.txt exists and is readable"
|
||||
- "3. Container has /run/secrets/redis_password file mounted"
|
||||
- "4. Container environment has REDIS_PASSWORD_FILE variable set"
|
||||
- "5. PHP can read the secret file and resolve REDIS_PASSWORD"
|
||||
- "6. Redis container requires password (requirepass set)"
|
||||
- ""
|
||||
- "If any check fails, the issue is identified above."
|
||||
239
deployment/ansible/playbooks/deploy/production.yml
Normal file
239
deployment/ansible/playbooks/deploy/production.yml
Normal file
@@ -0,0 +1,239 @@
|
||||
---
|
||||
- name: Deploy Application Update to Production via Docker Compose
|
||||
hosts: production
|
||||
gather_facts: yes
|
||||
become: no
|
||||
|
||||
vars:
|
||||
# These should be passed via -e from CI/CD
|
||||
application_environment: production
|
||||
application_compose_suffix: production.yml
|
||||
# app_stack_path is now defined in group_vars/production.yml
|
||||
|
||||
pre_tasks:
|
||||
- name: Set deployment variables
|
||||
set_fact:
|
||||
image_tag: "{{ image_tag | default('latest') }}"
|
||||
git_commit_sha: "{{ git_commit_sha | default('unknown') }}"
|
||||
deployment_timestamp: "{{ deployment_timestamp | default(ansible_date_time.iso8601) }}"
|
||||
- name: Optionally load registry credentials from encrypted vault
|
||||
include_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/production.vault.yml"
|
||||
no_log: yes
|
||||
ignore_errors: yes
|
||||
delegate_to: localhost
|
||||
become: no
|
||||
|
||||
- name: Derive docker registry credentials from vault when not provided
|
||||
set_fact:
|
||||
docker_registry_username: "{{ docker_registry_username | default(vault_docker_registry_username | default(docker_registry_username_default)) }}"
|
||||
docker_registry_password: "{{ docker_registry_password | default(vault_docker_registry_password | default(docker_registry_password_default)) }}"
|
||||
|
||||
- name: Ensure system packages are up to date
|
||||
include_role:
|
||||
name: system
|
||||
when: system_update_packages | bool
|
||||
|
||||
- name: Verify Docker is running
|
||||
systemd:
|
||||
name: docker
|
||||
state: started
|
||||
register: docker_service
|
||||
become: yes
|
||||
|
||||
- name: Fail if Docker is not running
|
||||
fail:
|
||||
msg: "Docker service is not running"
|
||||
when: docker_service.status.ActiveState != 'active'
|
||||
|
||||
- name: Ensure application stack directory exists
|
||||
file:
|
||||
path: "{{ app_stack_path }}"
|
||||
state: directory
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: '0755'
|
||||
|
||||
- name: Check if docker-compose.base.yml exists in application stack
|
||||
stat:
|
||||
path: "{{ app_stack_path }}/docker-compose.base.yml"
|
||||
register: compose_base_exists
|
||||
when: not (application_sync_files | default(false) | bool)
|
||||
|
||||
- name: Check if docker-compose.production.yml exists in application stack
|
||||
stat:
|
||||
path: "{{ app_stack_path }}/docker-compose.production.yml"
|
||||
register: compose_override_exists
|
||||
when: not (application_sync_files | default(false) | bool)
|
||||
|
||||
- name: Fail if docker-compose files don't exist
|
||||
fail:
|
||||
msg: |
|
||||
Application Stack docker-compose files not found at {{ app_stack_path }}
|
||||
|
||||
Required files:
|
||||
- docker-compose.base.yml
|
||||
- docker-compose.production.yml
|
||||
|
||||
The Application Stack must be deployed first via:
|
||||
ansible-playbook -i inventory/production.yml playbooks/setup-infrastructure.yml
|
||||
|
||||
This will create the application stack with docker-compose files and .env file.
|
||||
when:
|
||||
- not (application_sync_files | default(false) | bool)
|
||||
- (not compose_base_exists.stat.exists or not compose_override_exists.stat.exists)
|
||||
|
||||
- name: Create backup directory
|
||||
file:
|
||||
path: "{{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}"
|
||||
state: directory
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: '0755'
|
||||
|
||||
tasks:
|
||||
- name: Verify docker-compose files exist
|
||||
stat:
|
||||
path: "{{ app_stack_path }}/docker-compose.base.yml"
|
||||
register: compose_base_check
|
||||
when: not (application_sync_files | default(false) | bool)
|
||||
|
||||
- name: Verify docker-compose.production.yml exists
|
||||
stat:
|
||||
path: "{{ app_stack_path }}/docker-compose.production.yml"
|
||||
register: compose_override_check
|
||||
when: not (application_sync_files | default(false) | bool)
|
||||
|
||||
- name: Fail if docker-compose files don't exist
|
||||
fail:
|
||||
msg: |
|
||||
Application Stack docker-compose files not found at {{ app_stack_path }}
|
||||
|
||||
Required files:
|
||||
- docker-compose.base.yml
|
||||
- docker-compose.production.yml
|
||||
|
||||
The Application Stack must be deployed first via:
|
||||
ansible-playbook -i inventory/production.yml playbooks/setup-infrastructure.yml
|
||||
|
||||
This will create the application stack with docker-compose files and .env file.
|
||||
when:
|
||||
- not (application_sync_files | default(false) | bool)
|
||||
- (not compose_base_check.stat.exists or not compose_override_check.stat.exists)
|
||||
|
||||
- name: Backup current deployment metadata
|
||||
shell: |
|
||||
docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.production.yml ps --format json 2>/dev/null > {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/current_containers.json || true
|
||||
docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.production.yml config 2>/dev/null > {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/docker-compose-config.yml || true
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
ignore_errors: yes
|
||||
when:
|
||||
- not (application_sync_files | default(false) | bool)
|
||||
- compose_base_exists.stat.exists | default(false)
|
||||
- compose_override_exists.stat.exists | default(false)
|
||||
|
||||
- name: Login to Docker registry (if credentials provided)
|
||||
community.docker.docker_login:
|
||||
registry_url: "{{ docker_registry_url }}"
|
||||
username: "{{ docker_registry_username }}"
|
||||
password: "{{ docker_registry_password }}"
|
||||
no_log: yes
|
||||
ignore_errors: yes
|
||||
when:
|
||||
- docker_registry_username is defined
|
||||
- docker_registry_password is defined
|
||||
- docker_registry_username | length > 0
|
||||
- docker_registry_password | length > 0
|
||||
register: registry_login
|
||||
|
||||
- name: Pull new Docker image
|
||||
community.docker.docker_image:
|
||||
name: "{{ app_image }}"
|
||||
tag: "{{ image_tag }}"
|
||||
source: pull
|
||||
force_source: yes
|
||||
register: image_pull
|
||||
|
||||
- name: Verify image was pulled successfully
|
||||
fail:
|
||||
msg: "Failed to pull image {{ app_image }}:{{ image_tag }}"
|
||||
when: image_pull.failed
|
||||
|
||||
# Sync files first if application_sync_files=true (before updating docker-compose.production.yml)
|
||||
- name: Sync application stack files
|
||||
import_role:
|
||||
name: application
|
||||
vars:
|
||||
application_sync_files: "{{ application_sync_files | default(false) }}"
|
||||
application_compose_recreate: "never" # Don't recreate yet, just sync files
|
||||
application_remove_orphans: false
|
||||
when: application_sync_files | default(false) | bool
|
||||
|
||||
- name: Update docker-compose.production.yml with new image tag (all services)
|
||||
replace:
|
||||
path: "{{ app_stack_path }}/docker-compose.production.yml"
|
||||
# Match both localhost:5000 and registry.michaelschiemer.de (or any registry URL)
|
||||
regexp: '^(\s+image:\s+)(localhost:5000|registry\.michaelschiemer\.de|{{ docker_registry }})/{{ app_name }}:.*$'
|
||||
replace: '\1{{ app_image }}:{{ image_tag }}'
|
||||
# Always update to ensure localhost:5000 is used (registry only accessible via localhost)
|
||||
when: true
|
||||
register: compose_updated
|
||||
|
||||
- name: Redeploy application stack with new image
|
||||
import_role:
|
||||
name: application
|
||||
vars:
|
||||
application_sync_files: false # Already synced above, don't sync again
|
||||
application_compose_recreate: "always"
|
||||
application_remove_orphans: true
|
||||
|
||||
- name: Get deployed image information
|
||||
shell: |
|
||||
docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.production.yml config | grep -E "^\s+image:" | head -1 | awk '{print $2}' || echo "unknown"
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: deployed_image
|
||||
changed_when: false
|
||||
|
||||
- name: Record deployment metadata
|
||||
copy:
|
||||
content: |
|
||||
Deployment Timestamp: {{ deployment_timestamp }}
|
||||
Git Commit: {{ git_commit_sha }}
|
||||
Image Tag: {{ image_tag }}
|
||||
Deployed Image: {{ deployed_image.stdout }}
|
||||
Image Pull: {{ 'SUCCESS' if image_pull.changed else 'SKIPPED (already exists)' }}
|
||||
Stack Deploy: {{ 'UPDATED' if application_stack_changed else 'NO_CHANGE' }}
|
||||
Health Status: {{ application_health_output if application_health_output != '' else 'All services healthy' }}
|
||||
Health Check HTTP Status: {{ application_healthcheck_status }}
|
||||
dest: "{{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/deployment_metadata.txt"
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: '0644'
|
||||
|
||||
- name: Cleanup old backups (keep last {{ max_rollback_versions | default(5) }})
|
||||
shell: |
|
||||
cd {{ backups_path }}
|
||||
ls -dt */ 2>/dev/null | tail -n +{{ max_rollback_versions | default(5) + 1 }} | xargs -r rm -rf
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
ignore_errors: yes
|
||||
|
||||
post_tasks:
|
||||
- name: Display deployment summary
|
||||
debug:
|
||||
msg:
|
||||
- "=== Production Deployment Summary ==="
|
||||
- "Image: {{ app_image }}:{{ image_tag }}"
|
||||
- "Commit: {{ git_commit_sha }}"
|
||||
- "Timestamp: {{ deployment_timestamp }}"
|
||||
- "Image Pull: {{ 'SUCCESS' if image_pull.changed else 'SKIPPED' }}"
|
||||
- "Stack Deploy: {{ 'UPDATED' if application_stack_changed else 'NO_CHANGE' }}"
|
||||
- "Health Output: {{ application_health_output if application_health_output != '' else 'All services healthy' }}"
|
||||
- "Health Check HTTP Status: {{ application_healthcheck_status }}"
|
||||
- "Health Check URL: {{ health_check_url | default('https://michaelschiemer.de/health') }}"
|
||||
- ""
|
||||
- "Next: Verify application is healthy"
|
||||
226
deployment/ansible/playbooks/deploy/staging.yml
Normal file
226
deployment/ansible/playbooks/deploy/staging.yml
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
- name: Deploy Application Update to Staging via Docker Compose
|
||||
hosts: production
|
||||
gather_facts: yes
|
||||
become: no
|
||||
|
||||
vars:
|
||||
# These should be passed via -e from CI/CD
|
||||
application_environment: staging
|
||||
application_compose_suffix: staging.yml
|
||||
# app_stack_path is now defined in group_vars/production.yml
|
||||
|
||||
pre_tasks:
|
||||
- name: Set deployment variables
|
||||
set_fact:
|
||||
image_tag: "{{ image_tag | default('latest') }}"
|
||||
git_commit_sha: "{{ git_commit_sha | default('unknown') }}"
|
||||
deployment_timestamp: "{{ deployment_timestamp | default(ansible_date_time.iso8601) }}"
|
||||
- name: Optionally load registry credentials from encrypted vault
|
||||
include_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/production.vault.yml"
|
||||
no_log: yes
|
||||
ignore_errors: yes
|
||||
delegate_to: localhost
|
||||
become: no
|
||||
|
||||
- name: Derive docker registry credentials from vault when not provided
|
||||
set_fact:
|
||||
docker_registry_username: "{{ docker_registry_username | default(vault_docker_registry_username | default(docker_registry_username_default)) }}"
|
||||
docker_registry_password: "{{ docker_registry_password | default(vault_docker_registry_password | default(docker_registry_password_default)) }}"
|
||||
|
||||
- name: Ensure system packages are up to date
|
||||
include_role:
|
||||
name: system
|
||||
when: system_update_packages | bool
|
||||
|
||||
- name: Verify Docker is running
|
||||
systemd:
|
||||
name: docker
|
||||
state: started
|
||||
register: docker_service
|
||||
become: yes
|
||||
|
||||
- name: Fail if Docker is not running
|
||||
fail:
|
||||
msg: "Docker service is not running"
|
||||
when: docker_service.status.ActiveState != 'active'
|
||||
|
||||
- name: Set staging stack path
|
||||
set_fact:
|
||||
app_stack_path: "{{ staging_stack_path | default(stacks_base_path + '/staging') }}"
|
||||
backups_path: "{{ backups_base_path | default('~/deployment/backups') }}"
|
||||
|
||||
- name: Ensure application stack directory exists
|
||||
file:
|
||||
path: "{{ app_stack_path }}"
|
||||
state: directory
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: '0755'
|
||||
|
||||
- name: Check if docker-compose.base.yml exists in staging stack
|
||||
stat:
|
||||
path: "{{ app_stack_path }}/docker-compose.base.yml"
|
||||
register: compose_base_exists
|
||||
|
||||
- name: Check if docker-compose.staging.yml exists in staging stack
|
||||
stat:
|
||||
path: "{{ app_stack_path }}/docker-compose.staging.yml"
|
||||
register: compose_override_exists
|
||||
|
||||
- name: Fail if docker-compose files don't exist
|
||||
fail:
|
||||
msg: |
|
||||
Staging Stack docker-compose files not found at {{ app_stack_path }}
|
||||
|
||||
Required files:
|
||||
- docker-compose.base.yml
|
||||
- docker-compose.staging.yml
|
||||
|
||||
The Staging Stack must be deployed first via:
|
||||
ansible-playbook -i inventory/production.yml playbooks/setup-infrastructure.yml
|
||||
|
||||
This will create the staging stack with docker-compose files and .env file.
|
||||
when:
|
||||
- not compose_base_exists.stat.exists or not compose_override_exists.stat.exists
|
||||
|
||||
- name: Create backup directory
|
||||
file:
|
||||
path: "{{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}"
|
||||
state: directory
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: '0755'
|
||||
|
||||
tasks:
|
||||
- name: Verify docker-compose files exist
|
||||
stat:
|
||||
path: "{{ app_stack_path }}/docker-compose.base.yml"
|
||||
register: compose_base_check
|
||||
|
||||
- name: Verify docker-compose.staging.yml exists
|
||||
stat:
|
||||
path: "{{ app_stack_path }}/docker-compose.staging.yml"
|
||||
register: compose_override_check
|
||||
|
||||
- name: Fail if docker-compose files don't exist
|
||||
fail:
|
||||
msg: |
|
||||
Staging Stack docker-compose files not found at {{ app_stack_path }}
|
||||
|
||||
Required files:
|
||||
- docker-compose.base.yml
|
||||
- docker-compose.staging.yml
|
||||
|
||||
The Staging Stack must be deployed first via:
|
||||
ansible-playbook -i inventory/production.yml playbooks/setup-infrastructure.yml
|
||||
|
||||
This will create the staging stack with docker-compose files and .env file.
|
||||
when:
|
||||
- not compose_base_check.stat.exists or not compose_override_check.stat.exists
|
||||
|
||||
- name: Backup current deployment metadata
|
||||
shell: |
|
||||
docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.staging.yml ps --format json 2>/dev/null > {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/current_containers.json || true
|
||||
docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.staging.yml config 2>/dev/null > {{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/docker-compose-config.yml || true
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Login to Docker registry (if credentials provided)
|
||||
community.docker.docker_login:
|
||||
registry_url: "{{ docker_registry_url }}"
|
||||
username: "{{ docker_registry_username }}"
|
||||
password: "{{ docker_registry_password }}"
|
||||
no_log: yes
|
||||
ignore_errors: yes
|
||||
when:
|
||||
- docker_registry_username is defined
|
||||
- docker_registry_password is defined
|
||||
- docker_registry_username | length > 0
|
||||
- docker_registry_password | length > 0
|
||||
register: registry_login
|
||||
|
||||
- name: Pull new Docker image
|
||||
community.docker.docker_image:
|
||||
name: "{{ app_image }}"
|
||||
tag: "{{ image_tag }}"
|
||||
source: pull
|
||||
force_source: yes
|
||||
register: image_pull
|
||||
|
||||
- name: Verify image was pulled successfully
|
||||
fail:
|
||||
msg: "Failed to pull image {{ app_image }}:{{ image_tag }}"
|
||||
when: image_pull.failed
|
||||
|
||||
- name: Update docker-compose.staging.yml with new image tag (all services)
|
||||
replace:
|
||||
path: "{{ app_stack_path }}/docker-compose.staging.yml"
|
||||
# Match both localhost:5000 and registry.michaelschiemer.de (or any registry URL)
|
||||
regexp: '^(\s+image:\s+)(localhost:5000|registry\.michaelschiemer\.de|{{ docker_registry }})/{{ app_name }}:.*$'
|
||||
replace: '\1{{ app_image }}:{{ image_tag }}'
|
||||
register: compose_updated
|
||||
|
||||
- name: Redeploy staging stack with new image
|
||||
import_role:
|
||||
name: application
|
||||
vars:
|
||||
application_sync_files: false
|
||||
application_compose_recreate: "always"
|
||||
application_remove_orphans: true
|
||||
application_stack_path: "{{ app_stack_path }}"
|
||||
application_compose_files:
|
||||
- "{{ app_stack_path }}/docker-compose.base.yml"
|
||||
- "{{ app_stack_path }}/docker-compose.staging.yml"
|
||||
|
||||
- name: Get deployed image information
|
||||
shell: |
|
||||
docker compose -f {{ app_stack_path }}/docker-compose.base.yml -f {{ app_stack_path }}/docker-compose.staging.yml config | grep -E "^\s+image:" | head -1 | awk '{print $2}' || echo "unknown"
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: deployed_image
|
||||
changed_when: false
|
||||
|
||||
- name: Record deployment metadata
|
||||
copy:
|
||||
content: |
|
||||
Deployment Timestamp: {{ deployment_timestamp }}
|
||||
Git Commit: {{ git_commit_sha }}
|
||||
Image Tag: {{ image_tag }}
|
||||
Deployed Image: {{ deployed_image.stdout }}
|
||||
Image Pull: {{ 'SUCCESS' if image_pull.changed else 'SKIPPED (already exists)' }}
|
||||
Stack Deploy: {{ 'UPDATED' if application_stack_changed else 'NO_CHANGE' }}
|
||||
Health Status: {{ application_health_output if application_health_output != '' else 'All services healthy' }}
|
||||
Health Check HTTP Status: {{ application_healthcheck_status }}
|
||||
dest: "{{ backups_path }}/{{ deployment_timestamp | regex_replace(':', '-') }}/deployment_metadata.txt"
|
||||
owner: "{{ ansible_user }}"
|
||||
group: "{{ ansible_user }}"
|
||||
mode: '0644'
|
||||
|
||||
- name: Cleanup old backups (keep last {{ max_rollback_versions | default(5) }})
|
||||
shell: |
|
||||
cd {{ backups_path }}
|
||||
ls -dt */ 2>/dev/null | tail -n +{{ max_rollback_versions | default(5) + 1 }} | xargs -r rm -rf
|
||||
args:
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
ignore_errors: yes
|
||||
|
||||
post_tasks:
|
||||
- name: Display deployment summary
|
||||
debug:
|
||||
msg:
|
||||
- "=== Staging Deployment Summary ==="
|
||||
- "Image: {{ app_image }}:{{ image_tag }}"
|
||||
- "Commit: {{ git_commit_sha }}"
|
||||
- "Timestamp: {{ deployment_timestamp }}"
|
||||
- "Image Pull: {{ 'SUCCESS' if image_pull.changed else 'SKIPPED' }}"
|
||||
- "Stack Deploy: {{ 'UPDATED' if application_stack_changed else 'NO_CHANGE' }}"
|
||||
- "Health Output: {{ application_health_output if application_health_output != '' else 'All services healthy' }}"
|
||||
- "Health Check HTTP Status: {{ application_healthcheck_status }}"
|
||||
- "Health Check URL: {{ health_check_url | default('https://staging.michaelschiemer.de/health') }}"
|
||||
- ""
|
||||
- "Next: Verify application is healthy"
|
||||
138
deployment/ansible/playbooks/fix-staging-compose-secrets.yml
Normal file
138
deployment/ansible/playbooks/fix-staging-compose-secrets.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
- name: Fix Staging docker-compose.staging.yml with Redis Secrets
|
||||
hosts: production
|
||||
gather_facts: yes
|
||||
become: no
|
||||
|
||||
tasks:
|
||||
- name: Check current docker-compose.staging.yml on server
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
echo "=== Current staging-app environment (REDIS-related) ==="
|
||||
grep -A 50 "staging-app:" docker-compose.staging.yml | grep -A 30 "environment:" | grep -E "(REDIS_|CACHE_|SESSION_)" || echo "No Redis env vars found"
|
||||
echo ""
|
||||
echo "=== Current secrets section for staging-app ==="
|
||||
grep -A 10 "staging-app:" docker-compose.staging.yml | grep -A 15 "secrets:" || echo "Secrets section not found"
|
||||
echo ""
|
||||
echo "=== Secrets definitions at bottom ==="
|
||||
tail -30 docker-compose.staging.yml | grep -A 10 "secrets:" || echo "Secrets definitions not found"
|
||||
register: current_config
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display current configuration
|
||||
debug:
|
||||
msg: "{{ current_config.stdout_lines }}"
|
||||
|
||||
- name: Get repository root path
|
||||
shell: |
|
||||
cd "{{ playbook_dir }}/../../.."
|
||||
pwd
|
||||
register: repo_root
|
||||
changed_when: false
|
||||
delegate_to: localhost
|
||||
become: no
|
||||
|
||||
- name: Display repository root
|
||||
debug:
|
||||
msg: "Repository root: {{ repo_root.stdout }}"
|
||||
|
||||
- name: Check if docker-compose.staging.yml exists in repository
|
||||
stat:
|
||||
path: "{{ repo_root.stdout }}/docker-compose.staging.yml"
|
||||
register: compose_file_stat
|
||||
delegate_to: localhost
|
||||
become: no
|
||||
|
||||
- name: Read docker-compose.staging.yml from repository
|
||||
slurp:
|
||||
src: "{{ repo_root.stdout }}/docker-compose.staging.yml"
|
||||
register: compose_file_content
|
||||
when: compose_file_stat.stat.exists
|
||||
delegate_to: localhost
|
||||
become: no
|
||||
|
||||
- name: Write docker-compose.staging.yml to server
|
||||
copy:
|
||||
content: "{{ compose_file_content.content | b64decode }}"
|
||||
dest: "~/deployment/stacks/staging/docker-compose.staging.yml"
|
||||
mode: '0644'
|
||||
when: compose_file_stat.stat.exists
|
||||
|
||||
- name: Fail if docker-compose.staging.yml not found
|
||||
fail:
|
||||
msg: "Could not find docker-compose.staging.yml at {{ repo_root.stdout }}/docker-compose.staging.yml. Please ensure the file exists in the repository root."
|
||||
when: not compose_file_stat.stat.exists
|
||||
|
||||
- name: Verify updated docker-compose.staging.yml on server
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
echo "=== Updated staging-app environment (REDIS-related) ==="
|
||||
grep -A 50 "staging-app:" docker-compose.staging.yml | grep -A 30 "environment:" | grep -E "(REDIS_|CACHE_|SESSION_|_FILE)" || echo "No Redis env vars found"
|
||||
echo ""
|
||||
echo "=== Updated secrets section for staging-app ==="
|
||||
grep -A 10 "staging-app:" docker-compose.staging.yml | grep -A 15 "secrets:" || echo "Secrets section not found"
|
||||
echo ""
|
||||
echo "=== Secrets definitions at bottom ==="
|
||||
tail -30 docker-compose.staging.yml | grep -A 10 "redis_password:" || echo "Secrets definitions not found"
|
||||
register: updated_config
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display updated configuration
|
||||
debug:
|
||||
msg: "{{ updated_config.stdout_lines }}"
|
||||
|
||||
- name: Restart staging containers to apply changes
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml up -d --force-recreate
|
||||
register: restart_result
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display restart result
|
||||
debug:
|
||||
msg: "{{ restart_result.stdout_lines }}"
|
||||
|
||||
- name: Wait for containers to start
|
||||
pause:
|
||||
seconds: 10
|
||||
|
||||
- name: Check container status after fix
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml ps
|
||||
register: container_status
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display container status
|
||||
debug:
|
||||
msg: "{{ container_status.stdout_lines }}"
|
||||
|
||||
- name: Verify REDIS_PASSWORD_FILE in container
|
||||
shell: |
|
||||
echo "=== Checking REDIS_PASSWORD_FILE in staging-app container ==="
|
||||
docker exec staging-app env | grep REDIS_PASSWORD || echo "REDIS_PASSWORD variables not found"
|
||||
docker exec staging-app env | grep "_FILE" | grep REDIS || echo "REDIS_PASSWORD_FILE not found"
|
||||
echo ""
|
||||
echo "=== Checking /run/secrets/redis_password ==="
|
||||
docker exec staging-app ls -la /run/secrets/redis_password 2>&1 || echo "Secret file not found"
|
||||
register: container_check
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display container verification
|
||||
debug:
|
||||
msg: "{{ container_check.stdout_lines }}"
|
||||
|
||||
- name: Summary
|
||||
debug:
|
||||
msg:
|
||||
- "========================================"
|
||||
- "FIX SUMMARY"
|
||||
- "========================================"
|
||||
- "1. Updated docker-compose.staging.yml on server"
|
||||
- "2. Restarted staging containers"
|
||||
- "3. Verified REDIS_PASSWORD_FILE configuration"
|
||||
- ""
|
||||
- "Next steps:"
|
||||
- "- Check staging-app logs: docker logs staging-app"
|
||||
- "- Test Redis connection from staging-app container"
|
||||
- "- Verify no more NOAUTH errors in logs"
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
- name: Fix Staging Secrets Permissions
|
||||
hosts: production
|
||||
gather_facts: yes
|
||||
become: no
|
||||
|
||||
tasks:
|
||||
- name: Check secrets file permissions in staging-app container
|
||||
shell: |
|
||||
echo "=== Checking /run/secrets/redis_password permissions ==="
|
||||
docker exec staging-app ls -la /run/secrets/redis_password 2>&1 || echo "File not found"
|
||||
echo ""
|
||||
echo "=== Checking /run/secrets directory permissions ==="
|
||||
docker exec staging-app ls -la /run/secrets/ | head -10
|
||||
echo ""
|
||||
echo "=== Current user ==="
|
||||
docker exec staging-app whoami
|
||||
echo ""
|
||||
echo "=== Testing file read access ==="
|
||||
docker exec staging-app cat /run/secrets/redis_password 2>&1 | head -c 20 || echo "Cannot read file"
|
||||
echo "..."
|
||||
register: permissions_check
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display permissions check
|
||||
debug:
|
||||
msg: "{{ permissions_check.stdout_lines }}"
|
||||
|
||||
- name: Try to fix permissions via entrypoint modification
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
# Check if staging-app has an entrypoint that can be modified
|
||||
grep -A 5 "staging-app:" docker-compose.staging.yml | grep -A 10 "entrypoint:" | head -5
|
||||
register: entrypoint_check
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display entrypoint check
|
||||
debug:
|
||||
msg: "{{ entrypoint_check.stdout_lines }}"
|
||||
|
||||
- name: Check if we can read secrets as root in container
|
||||
shell: |
|
||||
echo "=== Reading secret as root ==="
|
||||
docker exec -u root staging-app cat /run/secrets/redis_password 2>&1 | head -c 20 || echo "Cannot read even as root"
|
||||
echo "..."
|
||||
echo ""
|
||||
echo "=== Checking file owner ==="
|
||||
docker exec -u root staging-app stat -c "%U:%G %a" /run/secrets/redis_password 2>&1 || echo "Cannot stat"
|
||||
register: root_check
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display root check
|
||||
debug: "{{ root_check.stdout_lines }}"
|
||||
debug:
|
||||
msg: "{{ root_check.stdout_lines }}"
|
||||
|
||||
- name: Check container user configuration
|
||||
shell: |
|
||||
cd ~/deployment/stacks/staging
|
||||
echo "=== staging-app user configuration ==="
|
||||
grep -A 20 "staging-app:" docker-compose.staging.yml | grep -E "(user:|USER)" || echo "No user specified (defaults to www-data)"
|
||||
register: user_config
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Display user configuration
|
||||
debug:
|
||||
msg: "{{ user_config.stdout_lines }}"
|
||||
|
||||
- name: Summary and Recommendations
|
||||
debug:
|
||||
msg:
|
||||
- "========================================"
|
||||
- "PERMISSIONS ISSUE ANALYSIS"
|
||||
- "========================================"
|
||||
- "The secret file exists but is not readable by the PHP process."
|
||||
- ""
|
||||
- "Possible solutions:"
|
||||
- "1. Run PHP-FPM as root (NOT RECOMMENDED for security)"
|
||||
- "2. Create a wrapper script that reads secrets as root and exports them"
|
||||
- "3. Modify entrypoint to chmod/chown secrets (may not work on /run/secrets)"
|
||||
- "4. Use environment variables instead of file-based secrets"
|
||||
- "5. Modify docker-compose to use a different secrets mount path with proper permissions"
|
||||
@@ -52,16 +52,15 @@
|
||||
path: "{{ deployment_path }}/docker-compose.staging.yml"
|
||||
register: staging_compose_exists
|
||||
|
||||
- name: Copy docker-compose files if missing
|
||||
- name: Copy docker-compose files (always update)
|
||||
copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ deployment_path }}/{{ item.dest }}"
|
||||
mode: '0644'
|
||||
force: yes
|
||||
loop:
|
||||
- { src: "docker-compose.base.yml", dest: "docker-compose.base.yml" }
|
||||
- { src: "docker-compose.staging.yml", dest: "docker-compose.staging.yml" }
|
||||
when: not (item.dest == "docker-compose.base.yml" and base_compose_exists.stat.exists) or
|
||||
not (item.dest == "docker-compose.staging.yml" and staging_compose_exists.stat.exists)
|
||||
delegate_to: localhost
|
||||
become: no
|
||||
|
||||
|
||||
@@ -55,37 +55,43 @@ DB_USERNAME=postgres
|
||||
DB_PASSWORD=<password>
|
||||
|
||||
# Redis (separate instance)
|
||||
REDIS_PASSWORD=<password>
|
||||
# Note: REDIS_PASSWORD is loaded from Docker Secret via REDIS_PASSWORD_FILE
|
||||
# See secrets/redis_password.txt file
|
||||
CACHE_PREFIX=staging
|
||||
|
||||
# Git
|
||||
GIT_REPOSITORY_URL=https://git.michaelschiemer.de/michael/michaelschiemer.git
|
||||
GIT_BRANCH=staging
|
||||
GIT_TOKEN=<token>
|
||||
# Note: GIT_TOKEN is loaded from Docker Secret via GIT_TOKEN_FILE
|
||||
# See secrets/git_token.txt file
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Initial Setup
|
||||
|
||||
The staging environment uses `docker-compose.staging.yml` in the repository root, which is used as an override for `docker-compose.base.yml`.
|
||||
|
||||
```bash
|
||||
# Create staging stack directory on server
|
||||
mkdir -p ~/deployment/stacks/staging
|
||||
|
||||
# Copy docker-compose.yml from repository
|
||||
cp deployment/stacks/staging/docker-compose.yml ~/deployment/stacks/staging/
|
||||
# Copy docker-compose files from repository
|
||||
cp docker-compose.base.yml ~/deployment/stacks/staging/
|
||||
cp docker-compose.staging.yml ~/deployment/stacks/staging/
|
||||
|
||||
# Create .env file
|
||||
cd ~/deployment/stacks/staging
|
||||
cp .env.example .env
|
||||
# Edit .env with staging-specific values
|
||||
# Create secrets directory and files
|
||||
mkdir -p ~/deployment/stacks/staging/secrets
|
||||
# Create secret files (redis_password.txt, db_user_password.txt, app_key.txt, etc.)
|
||||
# These files should contain the actual secret values
|
||||
|
||||
# Ensure networks exist
|
||||
docker network create traefik-public 2>/dev/null || true
|
||||
docker network create staging-internal 2>/dev/null || true
|
||||
|
||||
# Start staging stack
|
||||
docker compose up -d
|
||||
cd ~/deployment/stacks/staging
|
||||
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml up -d
|
||||
```
|
||||
|
||||
### Auto-Deployment
|
||||
@@ -99,7 +105,7 @@ docker compose up -d
|
||||
```bash
|
||||
# Check container status
|
||||
cd ~/deployment/stacks/staging
|
||||
docker compose ps
|
||||
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml ps
|
||||
|
||||
# Test staging URL
|
||||
curl https://staging.michaelschiemer.de/health
|
||||
@@ -150,7 +156,8 @@ docker logs staging-nginx
|
||||
```bash
|
||||
# Force code pull in staging-app
|
||||
docker exec staging-app bash -c "cd /var/www/html && git pull origin staging"
|
||||
docker compose restart staging-app staging-nginx
|
||||
cd ~/deployment/stacks/staging
|
||||
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml restart staging-app staging-nginx
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
@@ -159,6 +166,6 @@ To remove staging environment:
|
||||
|
||||
```bash
|
||||
cd ~/deployment/stacks/staging
|
||||
docker compose down -v # Removes volumes too
|
||||
docker compose -f docker-compose.base.yml -f docker-compose.staging.yml down -v # Removes volumes too
|
||||
docker network rm staging-internal
|
||||
```
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
# Staging Environment - Docker Compose Configuration
|
||||
# Separate stack for staging.michaelschiemer.de
|
||||
|
||||
services:
|
||||
# PHP-FPM Application Runtime
|
||||
staging-app:
|
||||
image: git.michaelschiemer.de:5000/framework:latest
|
||||
container_name: staging-app
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- staging-internal
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- APP_ENV=staging
|
||||
- APP_DEBUG=${APP_DEBUG:-true}
|
||||
- APP_URL=https://staging.michaelschiemer.de
|
||||
- APP_KEY=${APP_KEY:-}
|
||||
# Git Repository - clones staging branch
|
||||
- GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL:-}
|
||||
- GIT_BRANCH=staging
|
||||
- GIT_TOKEN=${GIT_TOKEN:-}
|
||||
- GIT_USERNAME=${GIT_USERNAME:-}
|
||||
- GIT_PASSWORD=${GIT_PASSWORD:-}
|
||||
# Database (can share with production or use separate)
|
||||
- DB_HOST=${DB_HOST:-postgres}
|
||||
- DB_PORT=${DB_PORT:-5432}
|
||||
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging}
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
# Redis
|
||||
- REDIS_HOST=staging-redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
# Cache
|
||||
- CACHE_DRIVER=redis
|
||||
- CACHE_PREFIX=${CACHE_PREFIX:-staging}
|
||||
# Session
|
||||
- SESSION_DRIVER=redis
|
||||
- SESSION_LIFETIME=${SESSION_LIFETIME:-120}
|
||||
# Queue
|
||||
- QUEUE_DRIVER=redis
|
||||
- QUEUE_CONNECTION=default
|
||||
volumes:
|
||||
- staging-code:/var/www/html
|
||||
- staging-storage:/var/www/html/storage
|
||||
- staging-logs:/var/www/html/storage/logs
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
# Override entrypoint to only start PHP-FPM (not nginx) + fix git ownership
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
# Load secrets from /run/secrets/
|
||||
echo "🔐 Loading secrets from /run/secrets/..."
|
||||
[ -f /run/secrets/DB_PASSWORD ] && export DB_PASSWORD="$(cat /run/secrets/DB_PASSWORD)" || true
|
||||
[ -f /run/secrets/APP_KEY ] && export APP_KEY="$(cat /run/secrets/APP_KEY)" || true
|
||||
[ -f /run/secrets/GIT_TOKEN ] && export GIT_TOKEN="$(cat /run/secrets/GIT_TOKEN)" || true
|
||||
|
||||
# Fix Git ownership issue
|
||||
# Ensure Git treats the mounted repository as safe regardless of owner
|
||||
git config --global --add safe.directory /var/www/html 2>/dev/null || true
|
||||
git config --system --add safe.directory /var/www/html 2>/dev/null || true
|
||||
|
||||
# Git Clone/Pull functionality
|
||||
if [ -n "$GIT_REPOSITORY_URL" ]; then
|
||||
echo ""
|
||||
echo "📥 Cloning/Pulling code from Git repository..."
|
||||
|
||||
GIT_BRANCH="${GIT_BRANCH:-main}"
|
||||
GIT_TARGET_DIR="/var/www/html"
|
||||
|
||||
# Setup Git credentials
|
||||
if [ -n "$GIT_TOKEN" ]; then
|
||||
GIT_URL_WITH_AUTH=$(echo "$GIT_REPOSITORY_URL" | sed "s|https://|https://${GIT_TOKEN}@|")
|
||||
elif [ -n "$GIT_USERNAME" ] && [ -n "$GIT_PASSWORD" ]; then
|
||||
GIT_URL_WITH_AUTH=$(echo "$GIT_REPOSITORY_URL" | sed "s|https://|https://${GIT_USERNAME}:${GIT_PASSWORD}@|")
|
||||
else
|
||||
GIT_URL_WITH_AUTH="$GIT_REPOSITORY_URL"
|
||||
fi
|
||||
|
||||
# Clone or pull
|
||||
if [ ! -d "$GIT_TARGET_DIR/.git" ]; then
|
||||
echo "📥 Cloning repository from $GIT_REPOSITORY_URL (branch: $GIT_BRANCH)..."
|
||||
if [ "$(ls -A $GIT_TARGET_DIR 2>/dev/null)" ]; then
|
||||
find "$GIT_TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name "storage" -exec rm -rf {} \; 2>/dev/null || true
|
||||
fi
|
||||
TEMP_CLONE="${GIT_TARGET_DIR}.tmp"
|
||||
rm -rf "$TEMP_CLONE" 2>/dev/null || true
|
||||
if git -c safe.directory=/var/www/html clone --branch "$GIT_BRANCH" --depth 1 "$GIT_URL_WITH_AUTH" "$TEMP_CLONE"; then
|
||||
find "$GIT_TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name "storage" -exec rm -rf {} \; 2>/dev/null || true
|
||||
find "$TEMP_CLONE" -mindepth 1 -maxdepth 1 ! -name "." ! -name ".." -exec mv {} "$GIT_TARGET_DIR/" \; 2>/dev/null || true
|
||||
rm -rf "$TEMP_CLONE" 2>/dev/null || true
|
||||
echo "✅ Repository cloned successfully"
|
||||
fi
|
||||
else
|
||||
echo "🔄 Pulling latest changes from $GIT_BRANCH..."
|
||||
cd "$GIT_TARGET_DIR"
|
||||
git -c safe.directory=/var/www/html fetch origin "$GIT_BRANCH" || echo "⚠️ Git fetch failed"
|
||||
git -c safe.directory=/var/www/html reset --hard "origin/$GIT_BRANCH" || echo "⚠️ Git reset failed"
|
||||
git -c safe.directory=/var/www/html clean -fd || true
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
if [ -f "$GIT_TARGET_DIR/composer.json" ]; then
|
||||
echo "📦 Installing/updating Composer dependencies..."
|
||||
cd "$GIT_TARGET_DIR"
|
||||
composer install --no-dev --optimize-autoloader --no-interaction --no-scripts || echo "⚠️ Composer install failed"
|
||||
composer dump-autoload --optimize --classmap-authoritative || true
|
||||
fi
|
||||
|
||||
echo "✅ Git sync completed"
|
||||
else
|
||||
echo ""
|
||||
echo "ℹ️ GIT_REPOSITORY_URL not set, using code from image"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📊 Environment variables:"
|
||||
env | grep -E "DB_|APP_" | grep -v "PASSWORD|KEY|SECRET" || true
|
||||
|
||||
echo ""
|
||||
echo "🛠️ Adjusting filesystem permissions..."
|
||||
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true
|
||||
find /var/www/html/storage /var/www/html/bootstrap/cache -type d -exec chmod 775 {} \; 2>/dev/null || true
|
||||
find /var/www/html/storage /var/www/html/bootstrap/cache -type f -exec chmod 664 {} \; 2>/dev/null || true
|
||||
|
||||
# Start PHP-FPM only (no nginx)
|
||||
echo ""
|
||||
echo "🚀 Starting PHP-FPM..."
|
||||
exec php-fpm
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "php-fpm-healthcheck || true"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
depends_on:
|
||||
staging-redis:
|
||||
condition: service_started
|
||||
|
||||
# Nginx Web Server
|
||||
staging-nginx:
|
||||
image: git.michaelschiemer.de:5000/framework:latest
|
||||
container_name: staging-nginx
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- traefik-public
|
||||
- staging-internal
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- APP_ENV=staging
|
||||
- APP_DEBUG=${APP_DEBUG:-true}
|
||||
# Git Repository - clones staging branch
|
||||
- GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL:-}
|
||||
- GIT_BRANCH=staging
|
||||
- GIT_TOKEN=${GIT_TOKEN:-}
|
||||
- GIT_USERNAME=${GIT_USERNAME:-}
|
||||
- GIT_PASSWORD=${GIT_PASSWORD:-}
|
||||
volumes:
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- staging-code:/var/www/html:ro
|
||||
- staging-storage:/var/www/html/storage:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
# Wait for code to be available (cloned by staging-app container) then start nginx
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
# Wait for code to be available in shared volume (staging-app container clones it)
|
||||
GIT_TARGET_DIR="/var/www/html"
|
||||
echo "⏳ [staging-nginx] Waiting for code to be available in shared volume..."
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if [ -d "$$GIT_TARGET_DIR/public" ]; then
|
||||
echo "✅ [staging-nginx] Code found in shared volume"
|
||||
break
|
||||
fi
|
||||
echo " [staging-nginx] Waiting... ($$i/10)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# If code still not available after wait, try to copy from image as fallback
|
||||
if [ ! -d "$$GIT_TARGET_DIR/public" ] && [ -d "/var/www/html.orig" ]; then
|
||||
echo "⚠️ [staging-nginx] Code not found in shared volume, copying from image..."
|
||||
find /var/www/html.orig -mindepth 1 -maxdepth 1 ! -name "storage" -exec cp -r {} "$$GIT_TARGET_DIR/" \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Fix nginx upstream configuration - sites-enabled/default overrides conf.d/default.conf
|
||||
# This is critical: nginx sites-available/default uses 127.0.0.1:9000 but PHP-FPM runs in staging-app container
|
||||
if [ -f "/etc/nginx/sites-available/default" ]; then
|
||||
echo "🔧 [staging-nginx] Fixing PHP-FPM upstream configuration..."
|
||||
# Replace in upstream block
|
||||
sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || true
|
||||
sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || true
|
||||
# Replace any direct fastcgi_pass references too
|
||||
sed -i 's|fastcgi_pass 127.0.0.1:9000;|fastcgi_pass php-upstream;|g' /etc/nginx/sites-available/default || true
|
||||
sed -i 's|fastcgi_pass localhost:9000;|fastcgi_pass php-upstream;|g' /etc/nginx/sites-available/default || true
|
||||
echo "✅ [staging-nginx] PHP-FPM upstream fixed"
|
||||
fi
|
||||
|
||||
# Start nginx only (no PHP-FPM, no Git clone - staging-app container handles that)
|
||||
echo "🚀 [staging-nginx] Starting nginx..."
|
||||
exec nginx -g "daemon off;"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP Router for staging subdomain
|
||||
- "traefik.http.routers.staging.rule=Host(`staging.michaelschiemer.de`)"
|
||||
- "traefik.http.routers.staging.entrypoints=websecure"
|
||||
- "traefik.http.routers.staging.tls=true"
|
||||
- "traefik.http.routers.staging.tls.certresolver=letsencrypt"
|
||||
# Service
|
||||
- "traefik.http.services.staging.loadbalancer.server.port=80"
|
||||
# Middleware
|
||||
- "traefik.http.routers.staging.middlewares=default-chain@file"
|
||||
# Network
|
||||
- "traefik.docker.network=traefik-public"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://127.0.0.1/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
staging-app:
|
||||
condition: service_started
|
||||
|
||||
# Redis Cache/Session/Queue Backend (separate from production)
|
||||
staging-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: staging-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- staging-internal
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
command: >
|
||||
redis-server
|
||||
--requirepass ${REDIS_PASSWORD}
|
||||
--maxmemory 256mb
|
||||
--maxmemory-policy allkeys-lru
|
||||
--save 900 1
|
||||
--save 300 10
|
||||
--save 60 10000
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
volumes:
|
||||
- staging-redis-data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Queue Worker (Background Jobs)
|
||||
staging-queue-worker:
|
||||
image: git.michaelschiemer.de:5000/framework:latest
|
||||
container_name: staging-queue-worker
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- staging-internal
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- APP_ENV=staging
|
||||
- APP_DEBUG=${APP_DEBUG:-true}
|
||||
# Database
|
||||
- DB_HOST=${DB_HOST:-postgres}
|
||||
- DB_PORT=${DB_PORT:-5432}
|
||||
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging}
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
# Redis
|
||||
- REDIS_HOST=staging-redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
# Queue
|
||||
- QUEUE_DRIVER=redis
|
||||
- QUEUE_CONNECTION=default
|
||||
- QUEUE_WORKER_SLEEP=${QUEUE_WORKER_SLEEP:-3}
|
||||
- QUEUE_WORKER_TRIES=${QUEUE_WORKER_TRIES:-3}
|
||||
- QUEUE_WORKER_TIMEOUT=${QUEUE_WORKER_TIMEOUT:-60}
|
||||
volumes:
|
||||
- staging-code:/var/www/html
|
||||
- staging-storage:/var/www/html/storage
|
||||
- staging-logs:/var/www/html/storage/logs
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
command: php console.php queue:work --queue=default --timeout=${QUEUE_WORKER_TIMEOUT:-60}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "php -r 'exit(0);' && test -f /var/www/html/console.php || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
staging-app:
|
||||
condition: service_started
|
||||
staging-redis:
|
||||
condition: service_started
|
||||
|
||||
# Scheduler (Cron Jobs)
|
||||
staging-scheduler:
|
||||
image: git.michaelschiemer.de:5000/framework:latest
|
||||
container_name: staging-scheduler
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- staging-internal
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- APP_ENV=staging
|
||||
- APP_DEBUG=${APP_DEBUG:-true}
|
||||
# Database
|
||||
- DB_HOST=${DB_HOST:-postgres}
|
||||
- DB_PORT=${DB_PORT:-5432}
|
||||
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging}
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
# Redis
|
||||
- REDIS_HOST=staging-redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- staging-code:/var/www/html
|
||||
- staging-storage:/var/www/html/storage
|
||||
- staging-logs:/var/www/html/storage/logs
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
command: php console.php scheduler:run
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "php -r 'exit(0);' && test -f /var/www/html/console.php || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
staging-app:
|
||||
condition: service_started
|
||||
staging-redis:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
staging-code:
|
||||
name: staging-code
|
||||
staging-storage:
|
||||
name: staging-storage
|
||||
staging-logs:
|
||||
name: staging-logs
|
||||
staging-redis-data:
|
||||
name: staging-redis-data
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
staging-internal:
|
||||
driver: bridge
|
||||
Reference in New Issue
Block a user