Guide to building a GitHub Actions CI CD pipeline that runs tests and deploys a Flask Wi‑Fi QR code generator to an Ubuntu EC2 server over SSH
In this lesson we’ll build a simple CI/CD pipeline for a Flask application that generates Wi‑Fi QR codes. The pipeline uses GitHub Actions to run tests and deploy the app to an Ubuntu EC2 instance over SSH whenever changes are merged into main.Goals
Validate code with automated tests (pytest) on PRs and pushes.
On merge to main, deploy the latest code to an Ubuntu EC2 server via SSH.
Keep deployment credentials in GitHub Actions Secrets and automate the server-side deployment with a reusable deploy.sh script and a systemd service.
This guide shows a practical, production-focused example: local development → CI tests in GitHub Actions → SSH deploy to an Ubuntu EC2 instance. Adjust versions and service names to match your environment.
Below is a consolidated, production-friendly core of the Flask application used in the demo. This includes safe escaping for Wi‑Fi QR payload fields and a minimal route that returns a base64-encoded QR image suitable for embedding in templates.
Copy
# app.pyfrom flask import Flask, render_template, request, jsonifyimport ioimport base64import qrcodeimport osapp = Flask(__name__)def escape_wifi_text(s: str) -> str: """ Escape special characters per Wi‑Fi QR standard: backslash, semicolon, comma, colon, and quotes. """ if s is None: return "" s = s.replace("\\", "\\\\") s = s.replace(";", r"\;").replace(",", r"\,").replace(":", r"\:") s = s.replace("'", r"\'").replace('"', r'\"') return sdef build_wifi_payload(ssid: str, password: str, auth: str, hidden: bool) -> str: """ Build the Wi‑Fi QR payload: WIFI:T:<auth>;S:<ssid>;P:<password>;H:<hidden>;; When auth is "nopass" omit the P: field. """ auth_map = {"WPA/WPA2/WPA3": "WPA", "WEP": "WEP", "None (open)": "nopass"} t = auth_map.get(auth, "WPA") ssid_e = escape_wifi_text(ssid or "") pwd_e = escape_wifi_text(password or "") hidden_str = "true" if hidden else "false" if t == "nopass": payload = f"WIFI:T:{t};S:{ssid_e};H:{hidden_str};;" else: payload = f"WIFI:T:{t};S:{ssid_e};P:{pwd_e};H:{hidden_str};;" return payloaddef qr_image_base64(data: str, box_size: int = 10, border: int = 4) -> str: qr = qrcode.QRCode(box_size=box_size, border=border) qr.add_data(data) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") buffer = io.BytesIO() img.save(buffer, format="PNG") encoded = base64.b64encode(buffer.getvalue()).decode("ascii") return f"data:image/png;base64,{encoded}"@app.route("/", methods=["GET"])def index(): # Render a simple form (templates/index.html) return render_template("index.html")@app.route("/generate", methods=["POST"])def generate(): ssid = request.form.get("ssid", "") password = request.form.get("password", "") auth = request.form.get("auth", "WPA/WPA2/WPA3") hidden = request.form.get("hidden", "false") == "true" payload = build_wifi_payload(ssid, password, auth, hidden) img_b64 = qr_image_base64(payload) return jsonify({"payload": payload, "image": img_b64})if __name__ == "__main__": # Use FLASK_ENV for backward compatibility with older setups, but prefer explicit config. debug_mode = os.getenv("FLASK_ENV") == "development" or os.getenv("FLASK_DEBUG") == "1" app.run(debug=debug_mode, host="0.0.0.0", port=5001)
Note: In modern Flask versions, FLASK_ENV may be deprecated. Prefer configuring environment variables or a config object (e.g., FLASK_DEBUG) for new projects.
deploy.sh is intended for initial server bootstrap and can also be used for manual re-deploys. Below is an improved, annotated excerpt that you can place in your repo and run once on the EC2 instance during initial setup.
Copy
#!/bin/bashset -euo pipefailAPP_DIR="/opt/wifi-qr-generator"REPO_URL="https://github.com/YOUR_USERNAME/WIFI-QR-Code-Generator.git"SERVICE_NAME="wifi-qr-generator"USER="${SUDO_USER:-$(whoami)}"echo "Starting deployment of WiFi QR Generator..."sudo apt updatesudo apt upgrade -ysudo apt install -y python3 python3-pip python3-venv nginx git# Create app directory and ensure ownershipsudo mkdir -p "$APP_DIR"sudo chown "$USER:$USER" "$APP_DIR"if [ -d "$APP_DIR/.git" ]; then echo "Repository exists, pulling latest changes..." cd "$APP_DIR" git fetch origin git reset --hard origin/mainelse echo "Cloning repository..." git clone "$REPO_URL" "$APP_DIR" cd "$APP_DIR"fipython3 -m venv venvsource venv/bin/activatepip install --upgrade pippip install -r requirements.txt# Example nginx config copy (ensure your repo includes nginx.conf file)if [ -f "nginx.conf" ]; then sudo cp nginx.conf /etc/nginx/sites-available/$SERVICE_NAME sudo ln -sf /etc/nginx/sites-available/$SERVICE_NAME /etc/nginx/sites-enabled/$SERVICE_NAMEfi# Create a systemd service file if not present (make sure the unit file is included in repo or create it)if [ ! -f "/etc/systemd/system/$SERVICE_NAME.service" ]; then echo "Please add a systemd unit file at /etc/systemd/system/$SERVICE_NAME.service or include it in the repository."fisudo systemctl daemon-reloadsudo systemctl enable --now $SERVICE_NAMEsudo systemctl enable --now nginxecho "Deployment completed successfully!"echo "Your application should be accessible at http://YOUR_EC2_PUBLIC_IP"echo "To view logs: sudo journalctl -u $SERVICE_NAME -f"
Ensure the service runs using the virtualenv’s Python interpreter and restarts automatically.
Copy
# /etc/systemd/system/wifi-qr-generator.service (example)[Unit]Description=WiFi QR Generator Flask AppAfter=network.target[Service]User=ubuntuWorkingDirectory=/opt/wifi-qr-generatorEnvironment=PATH=/opt/wifi-qr-generator/venv/binEnvironment=FLASK_APP=app.pyEnvironment=FLASK_ENV=productionExecStart=/opt/wifi-qr-generator/venv/bin/python app.pyRestart=alwaysRestartSec=3StandardOutput=journalStandardError=journal[Install]WantedBy=multi-user.target
Note: FLASK_ENV is used here for clarity in the demo, but in new Flask releases prefer using FLASK_DEBUG or explicit configuration within your application.
Add these under Settings > Secrets and variables > Actions:
Secret name
Value
EC2_HOST
EC2 public IP (no protocol)
EC2_USER
e.g., ubuntu
EC2_SSH_KEY
full private key (PEM/ED25519) content, unencrypted
Never commit private keys or other secrets to the repository. Use GitHub Actions secrets. For CI/CD, use a dedicated deployment key (no passphrase) rather than your personal key.
Initial automated runs failed due to passphrase-protected or incorrectly provisioned keys.
The author generated an ed25519 keypair, added the public key to the EC2 user’s authorized_keys, placed the private key into GitHub Secrets, and added a quick SSH test step to the workflow.
After an initial manual setup on the server (git clone and ./deploy.sh once), the automated workflow could reliably deploy subsequent changes.
Once the SSH keys and initial server setup were correct, GitHub Actions successfully ran tests and deployed the app. The repository’s templates were updated (e.g., the site title), and the EC2-hosted site reflected those changes.
Wrap up
This guide demonstrates how to scaffold a CI/CD pipeline using GitHub Actions and a simple server-side deploy process for an Ubuntu EC2 instance. The assistant-generated pipeline and scripts provide a solid starting point, but expect to iterate on keys, permissions, and service configuration to reach a stable, secure deployment.Thank you for following along — after this setup, merging to main will run tests and automatically deploy the updated Flask application to your EC2 instance.