diff --git a/README.md b/README.md
index 55aa5af..f801303 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,7 @@ The project is built in incremental stages. Each stage adds a new DevOps capabil
- Stage 6: Ansible bootstrap & access control
- Stage 7: SSH hardening
- Stage 8: Docker installation (via Ansible)
-- Stage 9: Application deployment
+- Stage 9: Application deployment
- Stage 10: Monitoring stack (Prometheus & Grafana)
- Stage 11: TLS certificates & reverse proxy
@@ -275,6 +275,20 @@ for application deployment and monitoring.
Docker is intentionally not installed on the jump server.
+### Stage 9 — Application Deployment (Docker + Ansible)
+
+**What:**
+Deployed the Flask application container to the application server using Ansible.
+
+**Why:**
+A repeatable deployment reduces manual steps and ensures consistent environments.
+
+**How:**
+- Pulled a pinned image tag from GHCR (`ghcr.io/tysker/cloud_devops_app:77ecd38`).
+- Ran the container with `restart: unless-stopped`.
+- Exposed HTTP on port 80 mapped to container port 5000.
+- Added an Ansible health check against `/health`.
+
### Access Model
- Direct SSH access is allowed only to the jump server.
@@ -314,7 +328,8 @@ A chronological log describing the work done in each stage.
- Procced to Stage 6: Ansible bootstrap & access control
- Procced to Stage 7: SSH hardening
- Procced to Stage 8: Docker installation (via Ansible)
-- Procced to Stage 9 Application deployment using Docker and GHCR
+- Procced to Stage 9: Application deployment using Docker and GHCR
+- Procced to Stage 10: Stage 10: Monitoring stack (Prometheus & Grafana)
## Git Workflow & Conventions
diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml
index e708103..11b8e0c 100644
--- a/ansible/group_vars/all.yml
+++ b/ansible/group_vars/all.yml
@@ -1,3 +1,9 @@
ansible_python_interpreter: /usr/bin/python3
devops_user: devops
devops_public_key: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/linode.pub') }}"
+app_image: "ghcr.io/tysker/cloud_devops_app:77ecd38"
+app_container_name: "cloud-devops-app"
+app_container_port: 5000
+app_public_port: 80
+ghcr_username: "tysker"
+ghcr_token: "{{ lookup('env', 'GHCR_TOKEN') }}"
diff --git a/ansible/playbooks/deploy_app.yml b/ansible/playbooks/deploy_app.yml
new file mode 100644
index 0000000..def8e08
--- /dev/null
+++ b/ansible/playbooks/deploy_app.yml
@@ -0,0 +1,6 @@
+- name: Deploy Flask app container
+ hosts: app
+ gather_facts: true
+ become: true
+ roles:
+ - deploy_app
diff --git a/ansible/roles/deploy_app/tasks/main.yml b/ansible/roles/deploy_app/tasks/main.yml
new file mode 100644
index 0000000..8316bda
--- /dev/null
+++ b/ansible/roles/deploy_app/tasks/main.yml
@@ -0,0 +1,32 @@
+- name: (Optional) Login to GHCR
+ community.docker.docker_login:
+ registry_url: ghcr.io
+ username: "{{ ghcr_username }}"
+ password: "{{ ghcr_token }}"
+ when:
+ - ghcr_username is defined
+ - ghcr_token is defined
+ - ghcr_token | length > 0
+
+- name: Pull application image
+ community.docker.docker_image:
+ name: "{{ app_image }}"
+ source: pull
+
+- name: Ensure application container is running
+ community.docker.docker_container:
+ name: "{{ app_container_name }}"
+ image: "{{ app_image }}"
+ state: started
+ restart_policy: unless-stopped
+ ports:
+ - "{{ app_public_port }}:{{ app_container_port }}"
+
+- name: Wait for /health to return 200 on the server
+ ansible.builtin.uri:
+ url: "http://localhost/health"
+ status_code: 200
+ register: healthcheck
+ retries: 10
+ delay: 2
+ until: healthcheck.status == 200
diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf
index c0cee22..082e5d3 100644
--- a/infrastructure/terraform/main.tf
+++ b/infrastructure/terraform/main.tf
@@ -123,6 +123,14 @@ resource "linode_firewall" "monitoring_fw" {
ipv4 = ["192.168.0.0/16"]
}
+ inbound {
+ label = "allow-http"
+ action = "ACCEPT"
+ protocol = "TCP"
+ ports = "80"
+ ipv4 = ["0.0.0.0/0"]
+ }
+
inbound_policy = "DROP"
outbound_policy = "ACCEPT"