$ infrastructure/
├── ansible.cfg
├── inventory/
│ ├── hosts.yml
│ └── group_vars/
│ ├── all.yml
│ ├── dev.yml
│ ├── test.yml
│ ├── prod.yml
│ └── tools.yml
├── playbooks/
│ ├── site.yml # runs everything
│ ├── common.yml # base OS config
│ ├── -weight: 500;">docker.yml # Docker + Compose -weight: 500;">install
│ ├── monitoring.yml # Grafana/Loki/Promtail
│ ├── registry.yml # Harbor setup
│ └── app-deploy.yml # application deployment
└── roles/ ├── base/ # SSH hardening, firewall, packages ├── -weight: 500;">docker/ # Docker CE + Compose plugin ├── nginx/ # reverse proxy + SSL ├── monitoring/ # Grafana stack └── harbor/ # container registry
infrastructure/
├── ansible.cfg
├── inventory/
│ ├── hosts.yml
│ └── group_vars/
│ ├── all.yml
│ ├── dev.yml
│ ├── test.yml
│ ├── prod.yml
│ └── tools.yml
├── playbooks/
│ ├── site.yml # runs everything
│ ├── common.yml # base OS config
│ ├── -weight: 500;">docker.yml # Docker + Compose -weight: 500;">install
│ ├── monitoring.yml # Grafana/Loki/Promtail
│ ├── registry.yml # Harbor setup
│ └── app-deploy.yml # application deployment
└── roles/ ├── base/ # SSH hardening, firewall, packages ├── -weight: 500;">docker/ # Docker CE + Compose plugin ├── nginx/ # reverse proxy + SSL ├── monitoring/ # Grafana stack └── harbor/ # container registry
infrastructure/
├── ansible.cfg
├── inventory/
│ ├── hosts.yml
│ └── group_vars/
│ ├── all.yml
│ ├── dev.yml
│ ├── test.yml
│ ├── prod.yml
│ └── tools.yml
├── playbooks/
│ ├── site.yml # runs everything
│ ├── common.yml # base OS config
│ ├── -weight: 500;">docker.yml # Docker + Compose -weight: 500;">install
│ ├── monitoring.yml # Grafana/Loki/Promtail
│ ├── registry.yml # Harbor setup
│ └── app-deploy.yml # application deployment
└── roles/ ├── base/ # SSH hardening, firewall, packages ├── -weight: 500;">docker/ # Docker CE + Compose plugin ├── nginx/ # reverse proxy + SSL ├── monitoring/ # Grafana stack └── harbor/ # container registry
# inventory/hosts.yml
all: children: dev: hosts: dev-01: ansible_host: 10.0.1.10 test: hosts: test-01: ansible_host: 10.0.1.20 prod: hosts: prod-01: ansible_host: 10.0.1.30 tools: hosts: tools-01: ansible_host: 10.0.1.40
# inventory/hosts.yml
all: children: dev: hosts: dev-01: ansible_host: 10.0.1.10 test: hosts: test-01: ansible_host: 10.0.1.20 prod: hosts: prod-01: ansible_host: 10.0.1.30 tools: hosts: tools-01: ansible_host: 10.0.1.40
# inventory/hosts.yml
all: children: dev: hosts: dev-01: ansible_host: 10.0.1.10 test: hosts: test-01: ansible_host: 10.0.1.20 prod: hosts: prod-01: ansible_host: 10.0.1.30 tools: hosts: tools-01: ansible_host: 10.0.1.40
# roles/base/tasks/main.yml
- name: Set timezone community.general.timezone: name: Europe/Copenhagen - name: Install base packages ansible.builtin.-weight: 500;">dnf: name: - vim - -weight: 500;">curl - -weight: 500;">wget - htop - -weight: 500;">git - firewalld - fail2ban state: present - name: Harden SSH - -weight: 500;">disable password auth ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: "^#?PasswordAuthentication" line: "PasswordAuthentication no" notify: -weight: 500;">restart sshd - name: Harden SSH - -weight: 500;">disable root login ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: "^#?PermitRootLogin" line: "PermitRootLogin no" notify: -weight: 500;">restart sshd - name: Enable and -weight: 500;">start firewalld ansible.builtin.systemd: name: firewalld enabled: true state: started
# roles/base/tasks/main.yml
- name: Set timezone community.general.timezone: name: Europe/Copenhagen - name: Install base packages ansible.builtin.-weight: 500;">dnf: name: - vim - -weight: 500;">curl - -weight: 500;">wget - htop - -weight: 500;">git - firewalld - fail2ban state: present - name: Harden SSH - -weight: 500;">disable password auth ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: "^#?PasswordAuthentication" line: "PasswordAuthentication no" notify: -weight: 500;">restart sshd - name: Harden SSH - -weight: 500;">disable root login ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: "^#?PermitRootLogin" line: "PermitRootLogin no" notify: -weight: 500;">restart sshd - name: Enable and -weight: 500;">start firewalld ansible.builtin.systemd: name: firewalld enabled: true state: started
# roles/base/tasks/main.yml
- name: Set timezone community.general.timezone: name: Europe/Copenhagen - name: Install base packages ansible.builtin.-weight: 500;">dnf: name: - vim - -weight: 500;">curl - -weight: 500;">wget - htop - -weight: 500;">git - firewalld - fail2ban state: present - name: Harden SSH - -weight: 500;">disable password auth ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: "^#?PasswordAuthentication" line: "PasswordAuthentication no" notify: -weight: 500;">restart sshd - name: Harden SSH - -weight: 500;">disable root login ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: "^#?PermitRootLogin" line: "PermitRootLogin no" notify: -weight: 500;">restart sshd - name: Enable and -weight: 500;">start firewalld ansible.builtin.systemd: name: firewalld enabled: true state: started
# roles/-weight: 500;">docker/tasks/main.yml
- name: Add Docker CE repository ansible.builtin.command: cmd: -weight: 500;">dnf config-manager --add-repo https://download.-weight: 500;">docker.com/linux/centos/-weight: 500;">docker-ce.repo creates: /etc/-weight: 500;">yum.repos.d/-weight: 500;">docker-ce.repo - name: Install Docker CE and Compose plugin ansible.builtin.-weight: 500;">dnf: name: - -weight: 500;">docker-ce - -weight: 500;">docker-ce-cli - containerd.io - -weight: 500;">docker-compose-plugin state: present - name: Add deploy user to -weight: 500;">docker group ansible.builtin.user: name: "{{ deploy_user }}" groups: -weight: 500;">docker append: true - name: Enable and -weight: 500;">start Docker ansible.builtin.systemd: name: -weight: 500;">docker enabled: true state: started
# roles/-weight: 500;">docker/tasks/main.yml
- name: Add Docker CE repository ansible.builtin.command: cmd: -weight: 500;">dnf config-manager --add-repo https://download.-weight: 500;">docker.com/linux/centos/-weight: 500;">docker-ce.repo creates: /etc/-weight: 500;">yum.repos.d/-weight: 500;">docker-ce.repo - name: Install Docker CE and Compose plugin ansible.builtin.-weight: 500;">dnf: name: - -weight: 500;">docker-ce - -weight: 500;">docker-ce-cli - containerd.io - -weight: 500;">docker-compose-plugin state: present - name: Add deploy user to -weight: 500;">docker group ansible.builtin.user: name: "{{ deploy_user }}" groups: -weight: 500;">docker append: true - name: Enable and -weight: 500;">start Docker ansible.builtin.systemd: name: -weight: 500;">docker enabled: true state: started
# roles/-weight: 500;">docker/tasks/main.yml
- name: Add Docker CE repository ansible.builtin.command: cmd: -weight: 500;">dnf config-manager --add-repo https://download.-weight: 500;">docker.com/linux/centos/-weight: 500;">docker-ce.repo creates: /etc/-weight: 500;">yum.repos.d/-weight: 500;">docker-ce.repo - name: Install Docker CE and Compose plugin ansible.builtin.-weight: 500;">dnf: name: - -weight: 500;">docker-ce - -weight: 500;">docker-ce-cli - containerd.io - -weight: 500;">docker-compose-plugin state: present - name: Add deploy user to -weight: 500;">docker group ansible.builtin.user: name: "{{ deploy_user }}" groups: -weight: 500;">docker append: true - name: Enable and -weight: 500;">start Docker ansible.builtin.systemd: name: -weight: 500;">docker enabled: true state: started
# -weight: 500;">docker-compose.prod.yml
services: api: image: harbor.internal/myapp/api:${APP_VERSION} -weight: 500;">restart: unless-stopped environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__Default=${DB_CONNECTION_STRING} networks: - app-network deploy: resources: limits: memory: 2G frontend: image: harbor.internal/myapp/frontend:${APP_VERSION} -weight: 500;">restart: unless-stopped networks: - app-network db: image: postgres:16 -weight: 500;">restart: unless-stopped volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_DB=${DB_NAME} - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} networks: - app-network nginx: image: harbor.internal/myapp/nginx:${APP_VERSION} -weight: 500;">restart: unless-stopped ports: - "443:443" - "80:80" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - /etc/letsencrypt:/etc/letsencrypt:ro networks: - app-network volumes: pgdata: networks: app-network: driver: bridge
# -weight: 500;">docker-compose.prod.yml
services: api: image: harbor.internal/myapp/api:${APP_VERSION} -weight: 500;">restart: unless-stopped environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__Default=${DB_CONNECTION_STRING} networks: - app-network deploy: resources: limits: memory: 2G frontend: image: harbor.internal/myapp/frontend:${APP_VERSION} -weight: 500;">restart: unless-stopped networks: - app-network db: image: postgres:16 -weight: 500;">restart: unless-stopped volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_DB=${DB_NAME} - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} networks: - app-network nginx: image: harbor.internal/myapp/nginx:${APP_VERSION} -weight: 500;">restart: unless-stopped ports: - "443:443" - "80:80" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - /etc/letsencrypt:/etc/letsencrypt:ro networks: - app-network volumes: pgdata: networks: app-network: driver: bridge
# -weight: 500;">docker-compose.prod.yml
services: api: image: harbor.internal/myapp/api:${APP_VERSION} -weight: 500;">restart: unless-stopped environment: - ASPNETCORE_ENVIRONMENT=Production - ConnectionStrings__Default=${DB_CONNECTION_STRING} networks: - app-network deploy: resources: limits: memory: 2G frontend: image: harbor.internal/myapp/frontend:${APP_VERSION} -weight: 500;">restart: unless-stopped networks: - app-network db: image: postgres:16 -weight: 500;">restart: unless-stopped volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_DB=${DB_NAME} - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} networks: - app-network nginx: image: harbor.internal/myapp/nginx:${APP_VERSION} -weight: 500;">restart: unless-stopped ports: - "443:443" - "80:80" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - /etc/letsencrypt:/etc/letsencrypt:ro networks: - app-network volumes: pgdata: networks: app-network: driver: bridge
# playbooks/app-deploy.yml
- name: Deploy application hosts: "{{ target_env }}" vars_files: - "../inventory/group_vars/{{ target_env }}.yml" tasks: - name: Copy -weight: 500;">docker-compose file ansible.builtin.template: src: "-weight: 500;">docker-compose.{{ target_env }}.yml.j2" dest: "/opt/myapp/-weight: 500;">docker-compose.yml" - name: Pull latest images ansible.builtin.command: cmd: -weight: 500;">docker compose pull chdir: /opt/myapp - name: Deploy with zero downtime ansible.builtin.command: cmd: -weight: 500;">docker compose up -d ---weight: 500;">remove-orphans chdir: /opt/myapp
# playbooks/app-deploy.yml
- name: Deploy application hosts: "{{ target_env }}" vars_files: - "../inventory/group_vars/{{ target_env }}.yml" tasks: - name: Copy -weight: 500;">docker-compose file ansible.builtin.template: src: "-weight: 500;">docker-compose.{{ target_env }}.yml.j2" dest: "/opt/myapp/-weight: 500;">docker-compose.yml" - name: Pull latest images ansible.builtin.command: cmd: -weight: 500;">docker compose pull chdir: /opt/myapp - name: Deploy with zero downtime ansible.builtin.command: cmd: -weight: 500;">docker compose up -d ---weight: 500;">remove-orphans chdir: /opt/myapp
# playbooks/app-deploy.yml
- name: Deploy application hosts: "{{ target_env }}" vars_files: - "../inventory/group_vars/{{ target_env }}.yml" tasks: - name: Copy -weight: 500;">docker-compose file ansible.builtin.template: src: "-weight: 500;">docker-compose.{{ target_env }}.yml.j2" dest: "/opt/myapp/-weight: 500;">docker-compose.yml" - name: Pull latest images ansible.builtin.command: cmd: -weight: 500;">docker compose pull chdir: /opt/myapp - name: Deploy with zero downtime ansible.builtin.command: cmd: -weight: 500;">docker compose up -d ---weight: 500;">remove-orphans chdir: /opt/myapp
ansible-playbook playbooks/app-deploy.yml -e target_env=prod
ansible-playbook playbooks/app-deploy.yml -e target_env=prod
ansible-playbook playbooks/app-deploy.yml -e target_env=prod
ansible-vault encrypt inventory/group_vars/prod.yml
ansible-vault encrypt inventory/group_vars/prod.yml
ansible-vault encrypt inventory/group_vars/prod.yml
- name: Deploy environment file ansible.builtin.template: src: env.j2 dest: /opt/myapp/.env owner: "{{ deploy_user }}" mode: "0600"
- name: Deploy environment file ansible.builtin.template: src: env.j2 dest: /opt/myapp/.env owner: "{{ deploy_user }}" mode: "0600"
- name: Deploy environment file ansible.builtin.template: src: env.j2 dest: /opt/myapp/.env owner: "{{ deploy_user }}" mode: "0600"
# roles/ci-agents/tasks/main.yml
- name: Create agent directory ansible.builtin.file: path: /opt/azdevops-agent state: directory owner: "{{ deploy_user }}" - name: Download and configure Azure DevOps agent ansible.builtin.shell: | -weight: 500;">curl -fsSL https://vstsagentpackage.azureedge.net/agent/{{ agent_version }}/vsts-agent-linux-x64-{{ agent_version }}.tar.gz | tar xz ./config.sh --unattended \ --url https://dev.azure.com/{{ azdo_org }} \ --auth pat --token {{ azdo_pat }} \ --pool {{ agent_pool }} \ --agent tools-01 args: chdir: /opt/azdevops-agent creates: /opt/azdevops-agent/.agent
# roles/ci-agents/tasks/main.yml
- name: Create agent directory ansible.builtin.file: path: /opt/azdevops-agent state: directory owner: "{{ deploy_user }}" - name: Download and configure Azure DevOps agent ansible.builtin.shell: | -weight: 500;">curl -fsSL https://vstsagentpackage.azureedge.net/agent/{{ agent_version }}/vsts-agent-linux-x64-{{ agent_version }}.tar.gz | tar xz ./config.sh --unattended \ --url https://dev.azure.com/{{ azdo_org }} \ --auth pat --token {{ azdo_pat }} \ --pool {{ agent_pool }} \ --agent tools-01 args: chdir: /opt/azdevops-agent creates: /opt/azdevops-agent/.agent
# roles/ci-agents/tasks/main.yml
- name: Create agent directory ansible.builtin.file: path: /opt/azdevops-agent state: directory owner: "{{ deploy_user }}" - name: Download and configure Azure DevOps agent ansible.builtin.shell: | -weight: 500;">curl -fsSL https://vstsagentpackage.azureedge.net/agent/{{ agent_version }}/vsts-agent-linux-x64-{{ agent_version }}.tar.gz | tar xz ./config.sh --unattended \ --url https://dev.azure.com/{{ azdo_org }} \ --auth pat --token {{ azdo_pat }} \ --pool {{ agent_pool }} \ --agent tools-01 args: chdir: /opt/azdevops-agent creates: /opt/azdevops-agent/.agent
-weight: 500;">docker tag myapp/api:latest harbor.internal/myapp/api:${BUILD_NUMBER}
-weight: 500;">docker push harbor.internal/myapp/api:${BUILD_NUMBER}
-weight: 500;">docker tag myapp/api:latest harbor.internal/myapp/api:${BUILD_NUMBER}
-weight: 500;">docker push harbor.internal/myapp/api:${BUILD_NUMBER}
-weight: 500;">docker tag myapp/api:latest harbor.internal/myapp/api:${BUILD_NUMBER}
-weight: 500;">docker push harbor.internal/myapp/api:${BUILD_NUMBER}
# azure-pipelines.yml (deploy stage)
- stage: Deploy jobs: - job: DeployProd pool: 'self-hosted-pool' steps: - script: | ansible-playbook playbooks/app-deploy.yml \ -e target_env=prod \ -e app_version=$(Build.BuildNumber) \ --vault-password-file /opt/secrets/vault-pass displayName: 'Deploy to production'
# azure-pipelines.yml (deploy stage)
- stage: Deploy jobs: - job: DeployProd pool: 'self-hosted-pool' steps: - script: | ansible-playbook playbooks/app-deploy.yml \ -e target_env=prod \ -e app_version=$(Build.BuildNumber) \ --vault-password-file /opt/secrets/vault-pass displayName: 'Deploy to production'
# azure-pipelines.yml (deploy stage)
- stage: Deploy jobs: - job: DeployProd pool: 'self-hosted-pool' steps: - script: | ansible-playbook playbooks/app-deploy.yml \ -e target_env=prod \ -e app_version=$(Build.BuildNumber) \ --vault-password-file /opt/secrets/vault-pass displayName: 'Deploy to production' - Harbor — private container registry with vulnerability scanning
- SonarQube — code quality and security analysis
- Grafana + Loki + Promtail — monitoring, log aggregation, and dashboards
- Vaultwarden — team password management
- Self-hosted CI/CD agents — Azure DevOps or Gitea runners