This project demonstrates how to deploy a container-based fullstack web application within a virtual environment using Ansible and Podman. We'll make an application stack including NGINX, PostgreSQL, and a monitoring solution with Prometheus and Grafana. All components are automated through reusable Ansible roles.
Container Stack Deployment With Ansible
Authors:
Filip Andersson
and
Jonatan Högild
24-02-2026
- Introduction
- Goals and Objectives
- Method
- Target Audience
- Document Status
- Disclaimer
- Scope and Limitations
- Environment
- Acknowledgments
- Implementation
10.1 Configuring reusable roles
10.2 The stack
10.3 Backend and DB
10.4 Deployment
10.5 Https/Tls Configuration - Conclusion
- References
12.1 Other projects in our virtual IT-enviroment
Welcome friend!
In this project we are going to deploy a container-based interactive web application using infrastructure-as-code (IaC), Ansible. We will deploy the fullstack containers on our application VM that will serve as our runtime enviroment and the monitoring containers on our metrics VM serving as our metrics collector. Everything will be managed from our management VM running Ansible. This will be done by configuring Ansible roles, and reusing those roles in playbooks. This is our fourth project in a series of projects with the end goal of setting up a complete virtualized, automated, and monitored IT-Enviroment as a part of our internship on The Swedish Meteorological and Hydrological Institute (SMHI) IT-department at the headquarters in Norrköping. The second goal of these projects are also supposed to serve as a set-up guide here on Github for anyone and everyone that wants to replicate what we have done. we will link every project to each other aswell.
Other projects in our virtual IT-enviroment
The goals and objectives of this project is:
- To run a container-based web application / full stack, with log in, displaying multiple interactive pages.
- Collect metrics from that app to the metrics VM, displaying it in Grafana.
- Doing it all through Ansible on the management VM
This is part of a larger ongoing IT-infrastructure project that will use Proxmox as a base, with Rocky Linux as the OS running on each virtual machine. The goal of this project is to build a complete IT-environment and gain a deeper understanding of the underlying components and their part in a larger production chain.
The solution was implemented using Ansible on a management virtual machine to automate the deployment of a container-based web application and monitoring on virtual on two machines running Podman. The container stack consisted of NGINX (frontend) that served visual presentation, Postgres (Database) for storing users and logging in, and Rocky linux based python api (Backend) as our backend api handling http requests and responses. To be able to serve multiple pages in the same window, a custom Cross-Origin-Resource-Sharing (CORS) function was created inside the backend api, since the enviroment is designed like most modern enterprises with restrictive access to the internet the containers couldn't be reliant on external python libraries and packages websites, instead a custom Rocky Linux based image was constructed using dnf as package handler, with access to internal package repositorys. The stack also consist of monitoring using container based Prometheus node exporters on each vm for exporting metrics configuring prometheus to collect metrics from the node exporters and prometheus as a data source for Grafana to visualize the result. Reusable Ansible roles and playbooks were used to install dependencies, pull images from a private image reg start containers with defined ports and volumes. To collect the container images from the private image registry, an ansible login role was composed and implemented with the mechanics of fetching confidential login credentials defined in the encrypted vault file in our ansible structure.
- This repo is for anyone who wants a step-by-step guide on how to deploy a modern container stack based application and monitoring stack with Ansible and Podman.
This repo is also part of a larger project aimed at people interested in learning about IT infrastructure, and building such an environment from scratch.
This repository is considered complete and officially published.
Future improvements, refinements, or corrections may be introduced through controlled updates. Any changes will be versioned and documented in the commit history.
Caution
This is intended for learning, testing, and experimentation. The emphasis is not on security or creating an operational environment suitable for production.
- The scope is inteded to serve as an internship project and learning opportunity when it comes to working with containers, and to run applications on our virtual machines with redundance.
- This guide is not intended for production-grade, multi-node clusters or advanced HA setups.
- Hardware compatibility varies; If unsure, check hardware requirements before proceeding.
- Instructions may become outdated as software updates; always verify with the official documentation.
- Sensitive information will be withheld. This will not hinder participation in the guide.
- Asus PN64 ax210NGW
- Intel® Core™ i7-12700H
- 1TB disk
- 64 GB memory
- FreeIPA (4.12.2)
- Proxmox VE (9.1.1)
- Rocky Linux (10.1)
- Ansible (core 2.16.14)
- Podman (5.6.0)
- python3
- python3-flask
- python3-psycopg2
- python3-dotenv
- python3-itsdangerous
creator(if)/image-name:verison_tag
- custom built image (rocky_flask_backend:latest)
- rocky linux (rockylinux:10-ubi-init)
- postgres (postgres:latest)
- custom prometheus podman exporter (prometheus-podman-exporter:latest)
- node-exporter (prom/node-exporter:latest)
- Prometheus (prom/prometheus:main)
- grafana (grafana:alpine-3.22.2)
- nginx (nginx:1.29.4)
- mkcert-v1.4.4-linux-amd64 (link)
Great thanks once again to our mentor Rafael for ongoing support and guidance. And thanks to Victor for insight and guidance.
- Note, that some details cant be disclosed due to company policy and that i will speak in general terms. For example the registry i will pull images from i will call "private-registry.com/repository"
Every config file is available under the code directory
What we want is resuable roles for repetetive tasks. We want a role for checking/installing latest versions of depencencies like Podman, we want a role that logs in to our private registry fetching our credentials from our encrypted vault file, We want a role for pulling images, and we want a role for running images acting as the whole container logic, our "engine" if you will. Each role will have a defaults folder with a main.yaml file and a tasks folder with a main.yaml file. All variables will be pre defined in our defaults/main as a standard fallback as is the best practice to never hardcode anything. Each tasks/main will contain the modules and define the logic for the tasks. Then, we want a role that shows running containers and a role for killing and removing containers, because it is quite a process to do manually for each container when we want to test our containers and such.
Very straight forward basic. The key to every task in ansible playbooks or roles is the use of ansible modules, like the ansible.builtin.dnf which allows the use of dnf package manager.
---
- name: Installation of tools
ansible.builtin.dnf:
name:
- podman
state: present
become: true
- name: Verify podman version
ansible.builtin.command: podman --versionAs the registry URL isnt as sensitive information as our
user credentials, its generally considered best practice to define it in our defaults and not our encrypted vault file
Out of necesity we to have the tls verify set to false, we ran into a lot of complications with certificate authentication on the vms so it is easier to do it like this so it dosent fail.
---
#Default image registry
registry_url: "www.private-container-registry.com/myrepository" # Dummy value for demostration purpose
tlsverify: tls-verify=falseThis is the logic for logging us in, using the module containers.podman.podman_login. Here im referring to the variables defined in our encrypted vault file.
- name: Login to private-registry
containers.podman.podman_login:
registry: "{{ registry_url }}"
username: "{{ registry_username }}"
password: "{{ registry_password }}"
tlsverify: false- Since some image paths varies, for example:
private-registry.com/myrepository/image:tag
private-registry.com/myrepository/manufacturer/image:tag
Its safer to create a mechanics for building the complete image name. Defining our default values in defaults and then overriding in pur playbooks, never hardcoding anything.
---
default_registry: "private-registry.com/repository"
#image basics:
default_image: "myimage"
default_tag: "latest"
tlsverify: falseThis is the mechanics for the image name builder, since we often want to pull multiple images we want to treat each image as an item in a list we overrode in our define in our playbook. This task runs once per item, tach image is treated as an item in a list, it builds the full image name (registry, optional manufacturer, image name, and version tag).
---
- name: Pull container images
containers.podman.podman_image:
name: >-
{{
default_registry
~ '/'
~ (item.get('manufacturer', '') ~ '/' if item.get('manufacturer', '') != '' else '')
~ item.image_name
~ ':'
~ (item.get('tag', default_tag))
}}
state: present---
#Default value for variables, whats the image to run + name of new container
# Default container registry
default_registry: "private-registry.com"
default_project: "project"
manufacturer: " "
image_name: my-image
tag: "latest"
container_name: my-container
#Desired container state / absent
container_state: started
#Additional settings for later
container_ports: []
container_env_vars: {}
container_volumes: []
container_restart_policy: always
container_cmd: []
container_network: " "
tls_verification: false---
- name: Run container
containers.podman.podman_container:
name: "{{ container_name }}"
image: "{{ default_registry }}/{{ default_project }}{% if manufacturer is defined and manufacturer | trim != '' %}/{{ manufacturer }}{% endif %}/{{ image_name }}:{{ tag }}"
state: "{{ container_state }}"
ports: "{{ container_ports | default(omit) }}"
env: "{{ container_env_vars }}"
volumes: "{{ container_volumes | default(omit) }}"
restart_policy: "{{ container_restart_policy | default('always') }}"
user: 0
command: "{{ container_cmd | default([]) }}"
tls_verify: "{{ tls_verification | default }}"
network: "{{ container_network | default(omit) }}"This role is very simple so we dont need default values per say so we only go with tasks for this one.
---
- name: Get all container names
command: sudo podman ps -a --format "{{'{{'}}.Names{{'}}'}}"
register: container_names
changed_when: false
- name: Show container names
debug:
msg: "Container found: {{ item }}"
loop: "{{ container_names.stdout_lines }}"
when: container_names.stdout != ""
Since the kill role is partially off the show role but with some tweaks its redundant to use defaults here aswell.
---
- name: Get all container names
command: podman ps -a --format "{{'{{'}}.Names{{'}}'}}"
register: container_names
changed_when: false
- name: Show container names
debug:
msg: "Container found: {{ item }}"
loop: "{{ container_names.stdout_lines }}"
when: container_names.stdout != ""
- name: Stop all containers
command: podman stop {{ item }}
loop: "{{ container_names.stdout_lines }}"
ignore_errors: true
when: container_names.stdout != ""
- name: Remove all containers
command: podman rm -f {{ item }}
loop: "{{ container_names.stdout_lines }}"
ignore_errors: true
when: container_names.stdout != ""What we want is to be able to log into our container based website, and we want our frontend to display something that speaks for what this is about with multiple pages, in this case we want it to speak about that this is our intern project at SMHI and a some information on our enviroment with diagrams.
On the application vm
└── app-praktik-projekt
├── backend
│ ├── app.py
│ ├── dnf-repos
│ │ └── yum.repos.d
│ │ ├── epel.repo
│ │ ├── rocky-extras.repo
│ │ └── rocky.repo
│ └── Dockerfile
├── db
│ └── init.sql
└── frontend
├── about.html
├── diagram.html
├── diagram.png
├── index.html
├── script.js
└── style.css<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SMHI Praktikprojekt</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="card">
<h1>
<span class="bold-part">SMHI</span>
Praktikprojekt
</h1>
<p class="subtitle">ENTER</p>
<form id="loginForm">
<input id="username" placeholder="Username" required>
<input id="password" type="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</div>
<script src="script.js"></script>
</body>
</html><!DOCTYPE html>
<html>
<head>
<title>About</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script>
const user = localStorage.getItem("user");
if (!user) window.location.href = "/";
</script>
<div class="card">
<h1 id="welcome"></h1>
<p>This system is deployed using containers, Ansible and Podman.</p>
<button onclick="goDiagram()">VIEW ARCHITECTURE</button>
</div>
<script src="script.js"></script>
</body>
</html>This page will serve our flowchart diagram.
<!DOCTYPE html>
<html>
<head>
<title>Architecture</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<script>
const user = localStorage.getItem("user");
if (!user) window.location.href = "/";
</script>
<div class="card">
<h1>ARCHITECTURE</h1>
<img src="diagram.png" class="diagram">
<button onclick="goBack()">BACK</button>
</div>
<script src="script.js"></script>
</body>
</html>body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: 'Segoe UI', sans-serif;
color: white;
background: radial-gradient(circle at top left, #1f1f1f, #000000);
animation: fadeIn 1.2s ease-in;
}
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: 'Segoe UI', sans-serif;
color: white;
background: radial-gradient(circle at top left, #1f1f1f, #000000);
animation: fadeIn 1.2s ease-in;
}
.card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
padding: 50px;
border-radius: 15px;
text-align: center;
box-shadow: 0 0 40px rgba(255,255,255,0.1);
width: 400px;
}
h1 {
font-size: 3rem;
font-weight: 900;
letter-spacing: 2px;
margin-bottom: 10px;
}
.subtitle {
opacity: 0.6;
margin-bottom: 30px;
}
input {
width: 100%;
padding: 14px;
margin-bottom: 20px;
border: none;
border-radius: 8px;
background: #111;
color: white;
font-size: 1rem;
}
button {
width: 100%;
padding: 14px;
border: none;
border-radius: 8px;
font-weight: bold;
font-size: 1rem;
background: white;
color: black;
cursor: pointer;
transition: 0.3s;
}
button:hover {
background: #00ffcc;
box-shadow: 0 0 20px #00ffcc;
transform: translateY(-2px);
}
.diagram {
max-width: 100%;
margin: 20px 0;
border-radius: 10px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}This is the javascript that makes our web application interactive and will call our backend api
const api = "http://" + window.location.hostname + ":5000";
function login() {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
if (!username || !password) {
alert("Enter username and password");
return;
}
fetch(api + "/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
})
.then(response => {
if (!response.ok) {
return response.json().then(data => { throw new Error(data.message); });
}
return response.json();
})
.then(data => {
localStorage.setItem("user", username);
window.location.href = "about.html";
})
.catch(error => {
alert(error.message);
console.error("Login error:", error);
});
}
// Handle Enter key / form submit
document.getElementById("loginForm").addEventListener("submit", function(e) {
e.preventDefault();
login();
});
// Display welcome message
window.onload = function () {
const welcome = document.getElementById("welcome");
if (welcome) {
const user = localStorage.getItem("user");
welcome.textContent = "WELCOME, " + user.toUpperCase();
}
};
// Navigation
function goDiagram() { window.location.href = "diagram.html"; }
function goBack() { window.location.href = "about.html"; }Since we designed our system like an enterprise enviroment with limited access towards the internet, we could not be depended on external python libraries for our images to work. This is why we based our python backend container off of Rocky Linux because we already have access to internal package repositorys using dnf. We had access to python3-flask but not the python3-cors dependency, making the display of multiple web pages in the same window possible so we had to construct our own Cross-Origin-Resource_Sharing (CORS) in app.py under def login()
from flask import Flask, request, jsonify, make_response
import psycopg2
import os
app = Flask(__name__)
# Database connection
def get_connection():
return psycopg2.connect(
host=os.getenv("DB_HOST"),
database=os.getenv("DB_NAME"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD")
)
# Global CORS handler
@app.after_request
def add_cors_headers(response):
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
return response
#Login route
@app.route("/login", methods=["POST", "OPTIONS"])
def login():
# Handle browser preflight request
if request.method == "OPTIONS":
return make_response("", 200)
data = request.get_json()
if not data:
return jsonify({"message": "Invalid request"}), 400
username = data.get("username")
password = data.get("password")
if not username or not password:
return jsonify({"message": "Missing credentials"}), 400
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"SELECT 1 FROM users WHERE username = %s AND password = %s",
(username, password)
)
user = cur.fetchone()
cur.close()
conn.close()
if user:
return jsonify({"message": "Login successful"}), 200
else:
return jsonify({"message": "Invalid credentials"}), 401
except Exception as e:
print("Database error:", e)
return jsonify({"message": "Server error"}), 500
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)And our Dockerfile for building the backend image, with Rocky Linux as a base image. We used the dnf repo configs from the vm and copied it to the container.
FROM private-registry.com/repository/rockylinux:10-ubi-init
COPY dnf-repos/yum.repos.d /etc/yum.repos.d
RUN dnf install -y \
python3 \
python3-flask \
python3-psycopg2 \
python3-dotenv \
&& dnf clean all
WORKDIR /app
COPY . .
EXPOSE 5000
CMD ["python3", "app.py"]The database will be defined in our playbook however for our database we want to create a table called users, note that init.sql will only run once - initiating the table.
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
INSERT INTO users (username, password) VALUES
('filip', 'secretPasswordExmple1'),
('jonatan', 'secretPasswordExample2')
ON CONFLICT (username) DO NOTHING;---
- name: Deploy application
hosts: application
become: true
roles:
- role: containers/install
- role: containers/login/filip
tasks:
- name: Run postgres
include_role:
name: containers/run
vars:
container_network: app_network
container_name: postgres_database
image_name: postgres
tag: latest
container_ports:
- "5433:5432"
container_volumes:
- "/home/Filip/app_projects/app-praktik-projekt/db:/docker-entrypoint-initdb.d:Z"
container_env_vars:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass
POSTGRES_DB: appdb
container_state: started
container_restart_policy: always
- name: Wait for postgres
wait_for:
host: localhost
port: 5433
delay: 3
timeout: 30
- name: Run backend
include_role:
name: containers/run
vars:
container_network: app_network
container_name: rocky_flask_backend_api
image_name: rocky_flask_backend
tag: latest
container_ports:
- "5000:5000"
container_env_vars:
DB_HOST: postgres_database
DB_USER: appuser
DB_PASSWORD: apppass
DB_NAME: appdb
container_state: started
container_restart_policy: always
- name: Run frontend
include_role:
name: containers/run
vars:
container_network: app_network
container_name: nginx_frontend
image_name: nginx
tag: 1.29.4
container_ports:
- "8081:80"
container_volumes:
- "/home/Filip/app_projects/app-praktik-projekt/frontend:/usr/share/nginx/html:Z"
container_cmd:
- "sh"
- "-c"
- "chown -R 0:0 /usr/share/nginx/html && nginx -g 'daemon off;'"
container_env_vars: {}
container_state: started
container_restart_policy: always---
- name: Verify Node Exporter on all VMs
hosts: all
roles:
- role: containers/install
- role: containers/login/filip
become: true
tasks:
- name: Pull Node Exporter image
include_role:
name: containers/images/pull
vars:
images_to_pull:
- manufacturer: prom
image_name: node-exporter
tag: latest
- name: Enable podman socket
ansible.builtin.systemd:
name: podman.socket
state: started
enabled: true
- name: Run Node Exporter container
include_role:
name: containers/run
vars:
container_name: node_exporter
manufacturer: prom
image_name: node-exporter
tag: latest
container_ports:
- "9100:9100"
pid: host
container_network: host
container_state: started
container_restart_policy: always
container_volumes:
- /:/host:ro,rslave
container_cmd:
- "--path.rootfs=/host"
container_security_opt: "{{ ['label=disable'] if inventory_hostname in groups['ipaserver'] else omit }}"
- name: Deploy Podman Exporter on Application VM
hosts: application
become: true
roles:
- role: containers/login/filip
tasks:
- name: Pull Podman Exporter image
include_role:
name: containers/images/pull
vars:
images_to_pull:
- image_name: prometheus-podman-exporter
tag: patched
- name: Enable podman socket
ansible.builtin.systemd:
name: podman.socket
state: started
enabled: true
- name: Run Podman Exporter container
include_role:
name: containers/run
vars:
container_name: podman_exporter
image_name: prometheus-podman-exporter
tag: patched
container_state: started
container_ports:
- "9882:9882"
container_network: host
container_volumes:
- "/run/podman/podman.sock:/run/podman/podman.sock:ro"
container_env_vars:
CONTAINER_HOST: "unix:///run/podman/podman.sock"
container_user: root
container_restart_policy: always
# Prometheus
- name: Deploy Prometheus
hosts: monitoring
become: true
roles:
- role: containers/login/filip
tasks:
- name: Ensure Prometheus config directory exists
ansible.builtin.file:
path: /home/Filip/prometheus
state: directory
mode: "0755"
- name: Deploy Prometheus config
ansible.builtin.copy:
src: files/prometheus/prometheus.yml
dest: /home/Filip/prometheus/prometheus.yml
mode: "0644"
- name: Run prometheus container
include_role:
name: containers/run
vars:
container_name: prometheus
manufacturer: prom
image_name: prometheus
tag: main
container_ports:
- "9090:9090"
container_volumes:
- "/home/Filip/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:Z"
container_state: started
container_restart_policy: always
container_network: host
- name: Deploy Grafana
hosts: monitoring
become: true
tasks:
- name: Create Grafana provisioning directory
ansible.builtin.file:
path: /home/Filip/grafana/provisioning/datasources
state: directory
recurse: true
mode: "0755"
- name: Configure Grafana datasource
ansible.builtin.copy:
src: files/grafana/provisioning/datasources/datasource.yml
dest: /home/Filip/grafana/provisioning/datasources/datasource.yml
mode: "0644"
- name: Run Grafana container
include_role:
name: containers/run
vars:
container_name: grafana
image_name: grafana
tag: "alpine-3.22.2"
container_ports:
- "3000:3000"
container_volumes:
- "/home/Filip/grafana/provisioning:/etc/grafana/provisioning:Z"
container_state: started
container_restart_policy: always
container_network: hostTo remove the insecure connection warning in Firefox on the Showcase VM, we configured TLS for both the frontend (Nginx) and backend (Flask) using mkcert-generated certificates.
Using the mkcert binary on Showcase-01:
./mkcert-v1.4.4-linux-amd64 This generates two files:
<IP>.pem— the server certificate<IP>-key.pem— the private key
The rootCA is stored at:
./mkcert-v1.4.4-linux-amd64 -CAROOT
# /home/Filip/.local/share/mkcert/rootCA.pemCerts are stored under Ansible files on Mgmt-01:
/opt/ansible/files/certificates/app-01-certs/
An Nginx config file is stored at /opt/ansible/files/nginx/nginx.conf and copied
to App-01 automatically by the playbook:
server {
listen 443 ssl;
ssl_certificate /certs/.pem;
ssl_certificate_key /certs/-key.pem;
root /usr/share/nginx/html;
index index.html;
}The deploy_app playbook handles everything automatically:
- name: Ensure certs directory exists
file:
path: /home/Filip/certs
state: directory
- name: Copy certs to app-01
copy:
src: /opt/ansible/files/certificates/app-01-certs/
dest: /home/Filip/certs/
- name: Copy nginx config
copy:
src: /opt/ansible/files/nginx/nginx.conf
dest: /home/Filip/app_projects/app-praktik-projekt/nginx.confFrontend container mounts:
container_ports:
- "443:443"
container_volumes:
- "/home/Filip/app_projects/app-praktik-projekt/frontend:/usr/share/nginx/html:Z"
- "/home/Filip/certs:/certs:Z"
- "/home/Filip/app_projects/app-praktik-projekt/nginx.conf:/etc/nginx/conf.d/default.conf:Z"Backend container mounts:
container_volumes:
- "/home/Filip/certs:/certs:Z"app.run(host="0.0.0.0", port=5000, ssl_context=(
"/certs/.pem",
"/certs/-key.pem"
))Import the rootCA into Firefox once so it permanently trusts all mkcert-generated certs:
Firefox → Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import
Import: /home/Filip/.local/share/mkcert/rootCA.pem
Check "Trust this CA to identify websites"
Working on this project as interns gave us a practical look at how modern infrastructure is actually built and maintained. Instead of only working with isolated tools, we had to understand how containers, automation, and monitoring fit together in a real environment. Designing solutions within the constraints of an enterprise setup—such as internal repositories and restricted internet access—also made the work feel closer to real production scenarios.
Beyond the technical result, the project helped us gain confidence in structuring infrastructure through automation and reproducible deployments. Seeing the application stack and monitoring come together across multiple virtual machines was both challenging and rewarding, and it gave us a clearer understanding of the systems that support real-world IT operations.
- Project 1 - Proxmox on Nuc
- Project 2 - Rocky Linux golden image for cloning
- Project 3 - Ansible on management VM
- Project 5 - FreeIPA for Virtual Enviroment