Deploy a Queue API with Docker and CI/CD
Build a Flask queue API, containerize it, and deploy to AWS EC2 with GitHub Actions.
Introduction
30 Second Summary
Every time you join a queue at a bank, a hospital counter, or a food delivery pickup point, there's a system behind the scenes managing who goes next. Businesses need APIs that handle this logic reliably, at scale, and without downtime.
In this project, you will build a Queue Management API called NoQ, containerize it with Docker, set up automated testing with GitHub Actions, and deploy it live on AWS EC2.
What You'll Build
A production-ready REST API for queue management, running in Docker containers with automated CI/CD and deployed to a public cloud server.
By the end of this project, you'll have:
- A Flask REST API with 4 endpoints for creating queues, generating tokens, and checking queue status.
- A Dockerfile and docker-compose.yml that containerize your application for consistent local development and deployment.
- A GitHub Actions workflow that automatically tests and builds your Docker image on every push.
- A live deployment on EC2 accessible from anywhere via a public IP address.
- Secret Mission: Add JWT authentication to protect your queue creation endpoints.
Are there any prerequisites?
You should have basic Python and Flask knowledge, basic Git usage, and an AWS account (free tier is fine). You'll install Docker Desktop during the project.
Before We Start
Before We Start...
Install Docker Desktop
Your NoQ API will run inside containers. Containers package your code with all its dependencies so it runs the same way everywhere.
To run containers locally, you need Docker Desktop. In this step, you will install Docker Desktop and verify it works by running a test container.
In this step, get ready to:
- Install Docker Desktop on your machine.
- Verify Docker and Docker Compose are working.
- Run a test container and view it in your browser.
Enable WSL 2 on Windows and install Docker Desktop
Docker Desktop uses different backends depending on your operating system. On Windows, it needs WSL 2 (Windows Subsystem for Linux) to run Linux containers.
macOS
- Open the Docker Desktop download page at https://docs.docker.com/desktop/setup/install/mac-install/
- Click the download button for your chip type (Apple chip or Intel chip).
- Double-click the downloaded Docker.dmg file to open the installer.
- Drag the Docker icon into the Applications folder.
- Double-click Docker.app in your Applications folder to launch Docker Desktop.
- Accept the Docker Subscription Service Agreement when prompted.
- Wait for the Docker engine to start. You will see the whale icon in your menu bar turn solid when it is ready.
What is Docker Desktop?
Docker Desktop is a desktop application that bundles the Docker Engine, Docker CLI, Docker Compose, and a GUI for managing containers. It handles all the virtualisation setup so you can run Linux containers on macOS or Windows.
Windows
Docker Desktop on Windows requires WSL 2 as its backend. You need to enable WSL 2 before installing Docker Desktop.
- Press the Windows key, type PowerShell, right-click Windows PowerShell, and select Run as administrator.
- Install WSL 2 by running this command:
wsl --install
- Restart your computer if prompted.
- After restarting, update WSL to the latest version by opening PowerShell as administrator again and running:
wsl --update
What does this command do?
The wsl --install command enables the Windows Subsystem for Linux feature and installs a default Linux distribution. Docker Desktop uses this Linux layer to run containers natively on Windows.
- Open the Docker Desktop download page at https://docs.docker.com/desktop/setup/install/windows-install/
- Click the download button to get Docker Desktop Installer.exe.
- Double-click Docker Desktop Installer.exe to run the installer.
- When prompted, ensure the Use WSL 2 instead of Hyper-V option is selected.
- Complete the installation and restart your computer if prompted.
- After restarting, launch Docker Desktop from the Start menu.
- Accept the Docker Subscription Service Agreement when prompted.
- Wait for the Docker engine to start. You will see the whale icon in your system tray turn solid when it is ready.
What is Docker Desktop?
Docker Desktop is a desktop application that bundles the Docker Engine, Docker CLI, Docker Compose, and a GUI for managing containers. On Windows, it uses WSL 2 to run a lightweight Linux VM behind the scenes.
Docker Desktop not starting?
On Windows, make sure hardware virtualisation is enabled in your BIOS/UEFI settings. Docker requires this to run containers.
On macOS, if you see a message about the app being damaged, quit VS Code and any terminal windows, then reinstall Docker Desktop.
ask me for help
Verify Docker installation
With Docker Desktop running, you can verify both the Docker CLI and Docker Compose are available in your terminal.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check the Docker version by running:
docker --version
- Check the Docker Compose version by running:
docker compose version
✔️ I see version numbers for both commands
Docker and Docker Compose are installed and ready. You should see output like Docker version 27.x.x and Docker Compose version v2.x.x.
ⓧ Command not found
This means Docker Desktop is not running or did not install correctly.
- Make sure Docker Desktop is open and the whale icon shows a solid state (not animating).
- Close and reopen your terminal window. New terminal sessions pick up updated PATH settings.
- If it still does not work, uninstall Docker Desktop and reinstall it following the steps above.
Run the test container
The best way to confirm Docker is fully working is to pull and run an actual container. Docker provides a small welcome image for exactly this purpose.
- Run the test container by entering this command in your terminal:
docker run -d -p 8080:80 docker/welcome-to-docker
What does this command do?
- docker run creates and starts a new container.
- -d runs the container in detached mode (in the background).
- -p 8080:80 maps port 8080 on your machine to port 80 inside the container.
- docker/welcome-to-docker is the image name Docker pulls from Docker Hub.
- Open your browser and go to http://localhost:8080.
You should see the Docker welcome page confirming your container is running successfully.
Don't see the welcome page?
Make sure Docker Desktop is still running. Check that no other application is using port 8080 on your machine.
If the page does not load, run docker ps in your terminal. You should see a container listed using the docker/welcome-to-docker image. If no container is listed, run the docker run command again.
help me troubleshoot my Docker container
- Stop and remove the test container by running:
docker stop $(docker ps -q --filter ancestor=docker/welcome-to-docker)
docker rm $(docker ps -aq --filter ancestor=docker/welcome-to-docker)
Clean up test containers
Stopping and removing the test container frees up port 8080 for later use. You will need port 5000 for your NoQ API, but keeping unused containers around can consume memory.
Docker is installed and working. Next up, you will build the Flask API that powers your queue management system.
Build the Flask API
Docker is installed and ready to containerize your app. Before you can containerize anything, you need an actual application to put inside the container.
In this step, you will build the NoQ Queue Management API from scratch using Flask. This API lets users create queues, generate numbered tokens, and check how many people are waiting.
In this step, get ready to:
- Set up the project structure with an application factory pattern.
- Implement 4 API endpoints for health checks and queue management.
- Test the API locally and verify all endpoints respond correctly.
Set up the project structure
Flask applications scale better when you use the application factory pattern. Instead of creating the app at module level, you wrap it inside a function called create_app(). This lets you create multiple instances for testing or different configurations.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and file structure by running these commands:
mkdir -p noq/app
cd noq
touch app/__init__.py app/routes.py app/models.py run.py requirements.txt
- Confirm the files exist by running:
ls app/ && ls
You should see __init__.py, routes.py, and models.py inside app/, plus run.py and requirements.txt in the project root.
Don't see the files?
Make sure you ran cd noq before running ls. If you see a "No such file or directory" error, check that the mkdir -p noq/app command ran successfully.
help me debug my project directory structure
- Open VS Code from inside your noq folder by running:
code .
VS Code opens with your project folder loaded in the sidebar.
- Open requirements.txt in the VS Code sidebar and add the project dependencies:
Flask==3.1.3
gunicorn==23.0.0
What are these dependencies?
Flask is the web framework that handles HTTP requests and routing. gunicorn is a production-grade WSGI server you will use later when running Flask inside Docker. You install it now so requirements.txt is complete from the start.
- Open app/models.py and add this placeholder content:
# Placeholder for database models (used in future iterations with SQLAlchemy)
# For this version, we use in-memory data structures in routes.py
Why keep an empty models file?
This file reserves the spot for database models when you add PostgreSQL later. Keeping it from the start means your project structure stays consistent as it grows.
- Save both files.
- Open app/__init__.py and add the application factory:
from flask import Flask
from app.routes import api_bp
def create_app():
app = Flask(__name__)
app.config["SECRET_KEY"] = "noq-secret-key-change-in-production"
app.register_blueprint(api_bp, url_prefix="/api")
return app
What does this code do?
create_app() is the application factory. It creates a new Flask instance, sets a secret key for session security, and registers the api_bp blueprint with the /api URL prefix.
Blueprints let you organize routes into separate modules. All endpoints defined in api_bp will automatically get the /api prefix added to their URLs.
- Save app/__init__.py.
- Open run.py and add the entry point for your application:
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
What does this code do?
This file is the entry point that starts your Flask server. It calls create_app() to build the application, then runs it on port 5000 with debug mode enabled.
host="0.0.0.0" means the server accepts connections from any network interface. This is required later when running inside a Docker container.
- Save run.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here are all the files you should have so far:
Flask==3.1.3
gunicorn==23.0.0
# Placeholder for database models (used in future iterations with SQLAlchemy)
# For this version, we use in-memory data structures in routes.py
from flask import Flask
from app.routes import api_bp
def create_app():
app = Flask(__name__)
app.config["SECRET_KEY"] = "noq-secret-key-change-in-production"
app.register_blueprint(api_bp, url_prefix="/api")
return app
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
Implement the 4 API endpoints
The NoQ API needs four endpoints. A health check confirms the service is running. The other three handle queue creation, token generation, and status retrieval.
- Open app/routes.py and add the imports, module-level variables, and the health check endpoint:
from flask import Blueprint, request, jsonify
from datetime import datetime
api_bp = Blueprint("api", __name__)
# In-memory storage for queues and tokens
queues = {}
tokens = {}
next_queue_id = 1
next_token_id = 1
@api_bp.route("/health", methods=["GET"])
def health_check():
return jsonify({"status": "healthy", "service": "noq-api", "timestamp": datetime.utcnow().isoformat()}), 200
What does this code do?
api_bp is a Flask Blueprint that groups related routes together. The module-level dictionaries (queues, tokens) act as in-memory storage. Data resets when the server restarts.
The /health endpoint returns a JSON response confirming the API is running. Health checks are essential for Docker containers and load balancers to know your service is alive.
- Save app/routes.py.
You will not see a change yet. The next endpoint makes the API functional.
- Below the health_check function in app/routes.py, add the queue creation endpoint:
@api_bp.route("/queues", methods=["POST"])
def create_queue():
global next_queue_id
data = request.get_json()
if not data or "name" not in data:
return jsonify({"error": "Queue name is required"}), 400
queue_id = next_queue_id
next_queue_id += 1
queues[queue_id] = {
"id": queue_id,
"name": data["name"],
"created_at": datetime.utcnow().isoformat(),
"current_token": 0,
"last_token": 0,
}
return jsonify(queues[queue_id]), 201
What does this code do?
This endpoint accepts a POST request with a JSON body containing a queue name. It validates the input, assigns an auto-incrementing ID, and stores the queue in memory.
The global keyword lets the function modify the module-level counter. The response returns the created queue with a 201 status code (Created).
- Save app/routes.py.
You will not see a change yet. The next endpoint handles token generation.
- Below the create_queue function, add the token generation endpoint:
@api_bp.route("/queues/<int:queue_id>/tokens", methods=["POST"])
def generate_token(queue_id):
global next_token_id
if queue_id not in queues:
return jsonify({"error": "Queue not found"}), 404
queues[queue_id]["last_token"] += 1
token_number = queues[queue_id]["last_token"]
token_id = next_token_id
next_token_id += 1
token_data = {
"id": token_id,
"queue_id": queue_id,
"token_number": token_number,
"created_at": datetime.utcnow().isoformat(),
}
tokens[token_id] = token_data
return jsonify(token_data), 201
What does this code do?
This endpoint generates a new token for a specific queue. It first checks if the queue exists, returning a 404 if not found.
If the queue exists, it increments last_token (the total tokens issued) and stores the new token data. Think of this as the machine at a deli counter dispensing numbered tickets.
- Save app/routes.py.
You will not see a change yet. One more endpoint to go.
- Below the generate_token function, add the queue status endpoint:
@api_bp.route("/queues/<int:queue_id>/status", methods=["GET"])
def get_queue_status(queue_id):
if queue_id not in queues:
return jsonify({"error": "Queue not found"}), 404
queue = queues[queue_id]
return jsonify({
"id": queue["id"],
"name": queue["name"],
"current_token": queue["current_token"],
"last_token": queue["last_token"],
"waiting": queue["last_token"] - queue["current_token"],
}), 200
What does this code do?
This endpoint returns the current state of a queue. The waiting field calculates how many people are still in line by subtracting current_token (who is being served now) from last_token (the last ticket issued).
- Save app/routes.py.
✔️ Awesome, I've got everything!
Great. Make sure app/routes.py is saved before moving on to testing.
ⓧ I'd like to double check the full code
Here is the complete app/routes.py file with all four endpoints:
from flask import Blueprint, request, jsonify
from datetime import datetime
api_bp = Blueprint("api", __name__)
queues = {}
tokens = {}
next_queue_id = 1
next_token_id = 1
@api_bp.route("/health", methods=["GET"])
def health_check():
return jsonify({"status": "healthy", "service": "noq-api", "timestamp": datetime.utcnow().isoformat()}), 200
@api_bp.route("/queues", methods=["POST"])
def create_queue():
global next_queue_id
data = request.get_json()
if not data or "name" not in data:
return jsonify({"error": "Queue name is required"}), 400
queue_id = next_queue_id
next_queue_id += 1
queues[queue_id] = {
"id": queue_id,
"name": data["name"],
"created_at": datetime.utcnow().isoformat(),
"current_token": 0,
"last_token": 0,
}
return jsonify(queues[queue_id]), 201
@api_bp.route("/queues/<int:queue_id>/tokens", methods=["POST"])
def generate_token(queue_id):
global next_token_id
if queue_id not in queues:
return jsonify({"error": "Queue not found"}), 404
queues[queue_id]["last_token"] += 1
token_number = queues[queue_id]["last_token"]
token_id = next_token_id
next_token_id += 1
token_data = {
"id": token_id,
"queue_id": queue_id,
"token_number": token_number,
"created_at": datetime.utcnow().isoformat(),
}
tokens[token_id] = token_data
return jsonify(token_data), 201
@api_bp.route("/queues/<int:queue_id>/status", methods=["GET"])
def get_queue_status(queue_id):
if queue_id not in queues:
return jsonify({"error": "Queue not found"}), 404
queue = queues[queue_id]
return jsonify({
"id": queue["id"],
"name": queue["name"],
"current_token": queue["current_token"],
"last_token": queue["last_token"],
"waiting": queue["last_token"] - queue["current_token"],
}), 200
Test the API locally
Your code is written. Now you need to install the dependencies and run the server to confirm everything works.
- Install the project dependencies by running this command from inside your noq folder:
pip install -r requirements.txt
Seeing a permission error or command not found?
If pip is not found, try pip3 instead. On some systems, Python 3's pip is only available as pip3.
help me install Flask dependencies for my project
- Start the Flask development server by running:
python run.py
You should see output showing the server running on http://0.0.0.0:5000. The terminal stays occupied while the server runs.
Seeing an ImportError?
If you see an error about app.routes not being found, make sure you are running the command from inside the noq folder (not from inside noq/app). The app directory must be a sibling of run.py.
help me fix my Flask import error
- Open a second terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Navigate back to your project folder by running:
cd ~/Desktop/noq
- Test the health endpoint by running:
curl http://localhost:5000/api/health
You should see a JSON response like:
{"service":"noq-api","status":"healthy","timestamp":"2026-06-06T12:00:00.000000"}
- Test queue creation by running:
curl -X POST http://localhost:5000/api/queues -H "Content-Type: application/json" -d '{"name": "Billing Counter"}'
You should see a response containing the new queue with "id": 1 and "name": "Billing Counter".
- Test token generation by running:
curl -X POST http://localhost:5000/api/queues/1/tokens
You should see a response with "token_number": 1 and "queue_id": 1.
- Test queue status by running:
curl http://localhost:5000/api/queues/1/status
You should see a response with "waiting": 1 showing one person in the queue.
What just happened?
You created a queue called "Billing Counter", generated one token for it, and then checked the status. The API correctly tracks that one person is waiting (last_token is 1, current_token is still 0, so 1 - 0 = 1 waiting).
- Stop the Flask server by switching to the first terminal and pressing Ctrl+C.
Seeing "Connection refused" on curl?
Make sure the Flask server is still running in your first terminal. If you accidentally closed it, run python run.py again from the noq folder.
help me connect to my local Flask server
Your API is working locally. Next up, you will containerize it with Docker so it runs the same way everywhere.
Containerize with Docker
Your Flask API works on your machine. But when you deploy it to a server, that server needs the same Python version, the same packages, and the same configuration. Without that, things break.
Docker solves this by packaging your app, its dependencies, and its runtime into a single container that runs identically everywhere. In this step, you'll write a Dockerfile and a docker-compose.yml to containerize your NoQ API.
In this step, get ready to:
- Write a Dockerfile that packages the Flask app with gunicorn.
- Write a docker-compose.yml that configures the container service with environment variables and health checks.
- Build and run the containerized API locally.
Write the Dockerfile
A Dockerfile is a recipe that tells Docker how to build an image of your application. Each instruction in the file adds a layer to that image.
- In your code editor, create a new file called Dockerfile (no extension) in the root of your noq folder.
- Add the following code to Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "run:app"]
What does this Dockerfile do?
- FROM python:3.11-slim starts from a lightweight Python 3.11 image as the base.
- WORKDIR /app sets the working directory inside the container.
- COPY requirements.txt . copies the requirements file first, so Docker can cache the dependency install layer separately.
- RUN pip install --no-cache-dir -r requirements.txt installs your Python packages without caching pip downloads (keeps the image smaller).
- COPY . . copies the rest of your application source code into the container.
- CMD starts gunicorn as the production server, binding to port 5000 with 2 worker processes.
Why gunicorn instead of flask run?
Flask's built-in development server is single-threaded and not designed for production traffic. Gunicorn is a production WSGI HTTP server that can handle multiple requests concurrently using worker processes.
Interviewers specifically test whether candidates know not to use flask run in production. Using gunicorn signals you understand production deployment.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile in the output.
Don't see Dockerfile?
- Make sure you're in the noq folder. Run pwd to check your current directory.
- The file must be named exactly Dockerfile with a capital D and no file extension.
Help me find my Dockerfile
Write docker-compose.yml
Docker Compose lets you define and run your container services in a single YAML file. Instead of typing long docker run commands with multiple flags, you declare everything in docker-compose.yml.
- Create a new file called docker-compose.yml in the root of your noq folder.
- Add the following code to docker-compose.yml:
services:
web:
build: .
ports:
- "5000:5000"
environment:
- FLASK_ENV=production
- SECRET_KEY=noq-secret-key-change-in-production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
What does this configuration do?
- build: . tells Docker Compose to build an image from the Dockerfile in the current directory.
- ports: "5000:5000" maps port 5000 on your machine to port 5000 inside the container.
- environment sets environment variables that your Flask app can read at runtime.
- healthcheck pings the /api/health endpoint every 30 seconds to confirm the app is responsive.
- restart: unless-stopped automatically restarts the container if it crashes, unless you manually stop it.
- Save docker-compose.yml.
- Confirm the file exists by running:
ls docker-compose.yml
You should see docker-compose.yml in the output.
File not showing up?
- Check the filename is exactly docker-compose.yml (with the hyphen, not underscore).
- Make sure you saved it in the noq folder alongside your Dockerfile.
Help me set up my docker-compose.yml
✔️ Awesome, I've got everything!
Great. Make sure both Dockerfile and docker-compose.yml are saved in your noq folder.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "run:app"]
Here is the complete docker-compose.yml:
services:
web:
build: .
ports:
- "5000:5000"
environment:
- FLASK_ENV=production
- SECRET_KEY=noq-secret-key-change-in-production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
Build and run the containerized API
Now it's time to build your Docker image and start the container. This single command reads your Dockerfile, installs everything, and launches the API inside a container.
- Make sure Docker Desktop is running on your machine.
- If you still have the Flask development server running from the previous step, stop it by pressing Ctrl+C in that terminal.
- Build and start the container by running this command from the noq folder:
docker compose up --build
What does this command do?
- docker compose up starts the services defined in docker-compose.yml.
- --build forces Docker to rebuild the image from scratch using your Dockerfile. Without this flag, Docker would use a cached image if one exists.
You'll see Docker pull the Python image, install your dependencies, and start gunicorn. The last few lines should show gunicorn booting with 2 workers.
- Open a new terminal window (keep the Docker container running in the first one).
- Verify the API is running inside the container by running:
curl http://localhost:5000/api/health
You should see a JSON response like this:
{"service":"noq-api","status":"healthy","timestamp":"2026-06-06T..."}
Seeing 'connection refused' or no response?
- Check that Docker Desktop is running. Look for the Docker whale icon in your system tray or menu bar.
- Make sure the previous flask run process is stopped. Port 5000 can only be used by one application at a time.
- Look at the terminal where docker compose up is running. If you see an error about port 5000 already in use, stop the other process and run docker compose up --build again.
- If you see a build error related to requirements.txt, make sure the file is saved and contains the correct package versions.
Help me fix my Docker container
- Once you've confirmed the health check works, stop the container by pressing Ctrl+C in the terminal where docker compose up is running.
Your NoQ API is now fully containerized. Anyone with Docker can run your app with a single command, regardless of their operating system or installed packages. Next up, you'll set up a CI/CD pipeline with GitHub Actions that automatically tests and builds this Docker image every time you push code.
Set Up GitHub Actions CI/CD
Your NoQ API is running locally inside Docker containers. That proves your app works on your machine. But how do you make sure it keeps working every time you change the code?
This is where CI/CD comes in. In this step, you'll create a GitHub Actions workflow that automatically tests your API and builds its Docker image every time you push code. If anything breaks, you'll know immediately.
In this step, get ready to:
- Create a CI/CD workflow file that tests your API automatically.
- Add a Docker build job that verifies your container starts.
- Push your code to GitHub and watch the pipeline run green.
Create the CI workflow with a test job
GitHub Actions workflows live inside a special folder in your repository. GitHub looks for YAML files in .github/workflows/ and runs them automatically based on triggers you define.
- Create the workflows directory by running this command in your terminal (make sure you're in the noq folder):
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github
You should see workflows in the output.
- Create a new file called ci.yml inside .github/workflows/ and paste the following code:
name: NoQ API CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run health check test
run: |
python -c "from app import create_app; app = create_app(); client = app.test_client(); resp = client.get('/api/health'); assert resp.status_code == 200; print('Health check passed!')"
What does this workflow do?
- name: NoQ API CI gives the workflow a human-readable label in the GitHub Actions tab.
- on: push/pull_request triggers the workflow whenever you push to main or open a pull request against it.
- runs-on: ubuntu-latest tells GitHub to spin up a fresh Linux server for the test.
- actions/checkout@v6 pulls your repository code onto that server.
- The final step uses Flask's built-in test client to hit /api/health and asserts a 200 response. If the assertion fails, the workflow fails too.
- Save .github/workflows/ci.yml.
You won't see a visible change yet. The workflow only runs after you push to GitHub, which you'll do in substep 3.
YAML syntax errors?
YAML is very sensitive to indentation. Make sure you're using spaces (not tabs) and that each level is indented by exactly 2 spaces. The jobs key should be at the root level (no indentation), and test should be indented 2 spaces under it.
help me fix YAML indentation in my GitHub Actions workflow
Add a build job that verifies the Docker image
The test job proves your Python code works. Now you need a second job that proves your Docker image builds and the container actually starts. This catches Dockerfile errors before you deploy.
- In .github/workflows/ci.yml, add the following build job directly below the test job (at the same indentation level as test:):
build:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t noq-api:${{ github.sha }} .
- name: Verify container starts
run: |
docker run -d --name noq-test -p 5000:5000 noq-api:${{ github.sha }}
sleep 5
curl -f http://localhost:5000/api/health
docker stop noq-test
What does the build job do?
- needs: test means this job only runs after the test job passes. If tests fail, the build is skipped.
- docker build -t noq-api:${{ github.sha }} builds your Docker image and tags it with the current commit hash. This gives every build a unique, traceable tag.
- The verify step runs the container, waits 5 seconds for it to start, then hits the health endpoint with curl -f. The -f flag makes curl return a non-zero exit code on HTTP errors, which fails the workflow.
- Save .github/workflows/ci.yml.
You won't see a change locally. The next substep pushes this to GitHub where the workflow will run.
Not sure where to paste the build job?
The build: line should start at column 3 (two spaces in), directly below the last line of the test job. It sits at the same level as test: because both are children of jobs:.
help me place the build job in the correct position in my ci.yml
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
name: NoQ API CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run health check test
run: |
python -c "from app import create_app; app = create_app(); client = app.test_client(); resp = client.get('/api/health'); assert resp.status_code == 200; print('Health check passed!')"
build:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t noq-api:${{ github.sha }} .
- name: Verify container starts
run: |
docker run -d --name noq-test -p 5000:5000 noq-api:${{ github.sha }}
sleep 5
curl -f http://localhost:5000/api/health
docker stop noq-test
Push to GitHub and verify the workflow runs
Your workflow file is ready locally. Now you need a GitHub repository to push it to. GitHub Actions will automatically detect the workflow file and run it.
- Open github.com/new in your browser.
- Enter noq as the repository name.
- Select Public for visibility (GitHub Actions is free and unlimited on public repos).
- Leave all checkboxes unchecked (no README, no .gitignore, no license). You already have code locally.
- Click Create repository.
Why public?
GitHub Actions gives public repositories unlimited free workflow minutes. Private repos have a monthly cap. For a portfolio project you want to show off, public is the best choice.
- Back in your terminal, commit all your project files and push them to GitHub by running these commands:
git add .
git commit -m "Add NoQ API with Docker and CI/CD"
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/noq.git"]]
git push -u origin main
What do these commands do?
- git add . stages all files in your project for the commit.
- git commit -m saves a snapshot of your code with a description.
- git branch -M main renames your current branch to main (matching the branch your workflow triggers on).
- git remote add origin connects your local repo to the GitHub repo you just created.
- git push -u origin main uploads your code. The -u flag sets main as the default push target for future pushes.
- In your browser, navigate to your GitHub repository.
- Click the Actions tab at the top of the page.
You should see a workflow run titled Add NoQ API with Docker and CI/CD in progress or completed. A green checkmark means both jobs passed.
Workflow takes about 1-2 minutes
The test job runs first (installing Python and dependencies), then the build job runs (building and verifying the Docker image). Refresh the page after a minute or two to see the final result.
Seeing a red X instead of green?
Click on the failed workflow run, then click the failed job to see the logs. Common causes:
- YAML indentation errors. Check that jobs: is at the root and test: / build: are both indented 2 spaces under it.
- Missing files. Make sure requirements.txt, run.py, and the app/ folder were all committed. Run git status to check.
- Dockerfile errors. If the build job fails, check that your Dockerfile is in the repository root.
help me debug my GitHub Actions workflow failure
Your CI/CD pipeline is live. Every push to main now triggers automated tests and a Docker build. Next up, you'll deploy this API to a real server on AWS so anyone can access it from the internet.
Deploy to AWS EC2
Your CI/CD pipeline is green and your Docker image builds successfully on every push. The final step is to deploy your containerized API to the cloud so anyone on the internet can reach it.
In this step, you will launch an EC2 instance, install Docker on it, clone your repository, and start the containers. The result is a live URL you can share in interviews or put on your resume.
In this step, get ready to:
- Launch a t3.micro EC2 instance with Amazon Linux 2023 and configure its Security Group.
- SSH into the instance and install Docker with Docker Compose.
- Clone your repository, start the containers, and verify the API is live.
Launch an EC2 instance and configure the Security Group
An EC2 instance is a virtual server in the AWS cloud. You need one running Amazon Linux 2023 so you can install Docker and run your containers in a place anyone on the internet can reach.
- In the AWS Management Console, use the search bar at the top and type EC2.
- Select EC2 from the results.
- Click Launch instance from the EC2 dashboard.
- Under Name and tags, enter noq-api as the instance name.
- Under Application and OS Images, confirm Amazon Linux 2023 AMI is selected (it is the default and marked Free tier eligible).
- Under Instance type, select t3.micro (Free tier eligible).
- Under Key pair (login), click Create new key pair.
- Enter noq-key as the key pair name.
- Leave the key pair type as RSA and format as .pem.
- Click Create key pair.
- Your browser downloads a file called noq-key.pem. Save it somewhere safe (you will need it to connect shortly).
Now configure the firewall rules so you can SSH in and access your API from a browser.
- Under Network settings, click Edit.
- Keep the default rule for SSH (port 22) with source set to 0.0.0.0/0.
- Click Add security group rule.
- Set Type to Custom TCP.
- Set Port range to 5000.
- Set Source to 0.0.0.0/0.
- In the Summary panel on the right, click Launch instance.
- Wait for the Success banner, then click the instance ID link to open the Instances page.
- Wait until the Instance state column shows Running and the Status check column shows 2/2 checks passed.
- Select your instance and copy the Public IPv4 address from the Details tab. You will use this IP address throughout the rest of this step.
What is a Security Group?
A Security Group acts as a virtual firewall for your EC2 instance. Each inbound rule you add specifies which traffic is allowed to reach the instance.
Port 22 lets you SSH in. Port 5000 lets the public reach your Flask API. Without the port 5000 rule, your API would run but nobody outside AWS could connect to it.
Why direct EC2 over a managed container service?
Managed services like ECS add task definitions, services, clusters, and container registries on top of Docker. For a fresher interview, demonstrating Docker plus SSH plus manual deploy to EC2 is the highest-impact skill because interviewers ask how you deploy a containerized app, not how you configure an ECS task definition.
SSH into EC2 and install Docker with Docker Compose
Your instance is running, but it is a bare Amazon Linux server with nothing installed. You need to connect via SSH, install Docker and the Docker Compose plugin, then clone your code.
- Open a terminal on your local machine.
- Set the permissions on your key file by running this command:
chmod 400 [[PEMPATH="path/to/noq-key.pem"]]
- Connect to your instance by running this command:
ssh -i [[PEMPATH="path/to/noq-key.pem"]] ec2-user@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
What does this command do?
- ssh opens a secure shell connection to the remote server.
- -i specifies the private key file that proves your identity.
- ec2-user is the default username on Amazon Linux 2023 instances.
- When prompted "Are you sure you want to continue connecting?", type yes and press Enter.
You should now see a prompt that starts with [ec2-user@. You are inside your EC2 instance.
- Update the system packages and install Docker by running these commands:
sudo yum update -y
sudo yum install -y docker
sudo service docker start
sudo usermod -a -G docker ec2-user
What do these commands do?
- yum update -y updates all installed packages to their latest versions.
- yum install -y docker installs the Docker engine from Amazon Linux 2023's built-in repository.
- service docker start launches the Docker daemon.
- usermod -a -G docker ec2-user adds your user to the docker group so you can run Docker commands without sudo.
- Install the Docker Compose plugin by running these commands:
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m) -o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
What does this do?
Amazon Linux 2023 does not ship the Docker Compose plugin by default. These commands download the latest Compose binary from GitHub and place it in the Docker CLI plugins directory so you can use docker compose commands.
- Log out and reconnect to pick up the new docker group permissions by running:
exit
- Reconnect to your instance by running the same SSH command:
ssh -i [[PEMPATH="path/to/noq-key.pem"]] ec2-user@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
- Verify Docker and Docker Compose are working by running:
docker info
docker compose version
You should see Docker system info followed by the Docker Compose version number.
- Install Git and clone your repository by running these commands:
sudo yum install -y git
git clone [[REPO_URL="https://github.com/YOUR_USERNAME/noq-api.git"]]
cd noq-api
Permission denied or command not found?
- If docker info says "permission denied", make sure you ran exit and reconnected. The group change only takes effect in a new session.
- If docker compose version says "is not a docker command", re-run the curl and chmod commands above. The binary may not have downloaded correctly.
Help me troubleshoot Docker installation on my EC2 instance.
Start the containers and verify the live API
Your EC2 instance now has Docker, Docker Compose, and your code. Time to start the same containers you ran locally and confirm the API is reachable from anywhere on the internet.
- Start the containers in detached mode by running:
docker compose up -d
What does the -d flag do?
The -d flag runs containers in detached mode (in the background). Without it, your SSH session would be locked to the container output and the containers would stop if you disconnected.
- Wait about 30 seconds for the image to build and the container to start. Check the container status by running:
docker compose ps
You should see the web container with a status of Up.
- Test the health endpoint from inside the instance by running:
curl http://localhost:5000/api/health
You should see a JSON response with "status": "healthy".
- Now test from your local machine. Open a browser and navigate to:
http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]:5000/api/health
You should see the same healthy JSON response in your browser. Your API is live and accessible from anywhere on the internet.
Can't reach the API from your browser?
- Make sure you are using http:// (not https://). The API does not have TLS configured.
- Confirm your Security Group has port 5000 open. In the EC2 console, select your instance, click the Security tab, and verify the inbound rules include port 5000 with source 0.0.0.0/0.
- If curl works on the instance but the browser does not connect, the Security Group rule is likely missing.
Help me debug why my EC2 API is not reachable from my browser.
Your NoQ API is live on the internet. Anyone with the URL can hit your endpoints. You just deployed a containerized production application to AWS.
Secret mission
Add JWT Authentication
Right now, anyone can create queues on your API. No login, no password, no questions asked. In a real production system, that is a security disaster. Your secret mission is to add JWT authentication so only registered users can create queues.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your EC2 instance is free tier eligible, so there are no charges as long as you remain within your free tier limits.
Cost warning
Your t3.micro EC2 instance is covered by the AWS Free Tier. Legacy accounts (created before July 15, 2025) get 750 hours per month for 12 months. New accounts (created after July 15, 2025) get a 6-month free plan or until credits are used.
If your free tier has expired or you are unsure of your account status, stop or terminate the instance to avoid charges.
Resources you used:
- AWS EC2 instance (t3.micro, Amazon Linux 2023).
- AWS Security Group (ports 22 and 5000).
- Key pair .pem file saved locally.
- Local project folder noq-api on your Desktop.
- GitHub repository with CI/CD workflow.
- Docker Desktop (installed in Step 1).
- Local Docker images and containers.
Keep everything running
No action needed. Choose this if you want a live API demo for interviews or plan to continue building on this project.
- Your EC2 instance continues running and is accessible at its public IP on port 5000.
- The free tier covers your t3.micro instance as long as you stay within limits.
- Your GitHub Actions pipeline continues running on every push to main.
- Docker Desktop remains installed on your machine for future containerization projects.
Pause - I'll come back to this later
Stop running processes to free up compute and memory while keeping all files and configurations intact.
- In the AWS Console, navigate to EC2 then Instances.
- Select your NoQ instance, click Instance state, then Stop instance.
- On your local machine, stop the Docker containers by running:
docker compose down
- Your EC2 instance keeps its attached storage and configuration. You can restart it anytime from the same Instance state menu.
Delete - I don't want to use this again
Remove all project resources completely. This cannot be undone.
Terminate the EC2 instance
- In the AWS Console, navigate to EC2 then Instances.
- Select your NoQ instance.
- Click Instance state, then Terminate instance.
- Confirm termination when prompted.
Delete the Security Group
- In the EC2 sidebar, click Security Groups under Network & Security.
- Select the Security Group you created for this project (with ports 22 and 5000).
- Click Actions, then Delete security groups.
- Confirm deletion.
Delete the key pair file
- Delete the .pem file you downloaded for SSH access. Run this from your terminal (replace the filename with your key pair name):
rm ~/Downloads/[[KEYPAIR_FILE="your-key-pair-name.pem"]]
Remove local project files
- Stop and remove all local Docker containers and images for this project:
cd ~/Desktop/noq-api
docker compose down
docker rmi noq-api-web
- Delete the local project folder:
rm -rf ~/Desktop/noq-api
Delete the GitHub repository
- Go to your repository on GitHub.
- Click Settings in the top navigation.
- Scroll to the bottom of the page to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Uninstall Docker Desktop
- On macOS: open Applications in Finder, drag Docker to the Trash, then empty the Trash.
- On Windows: open Settings, select Apps, find Docker Desktop, and click Uninstall.
That's a Wrap!
That's a Wrap!
Congratulations! You just shipped a production API to the cloud.
You built a Queue Management API from scratch, containerized it with Docker, automated testing with GitHub Actions, and deployed it live on AWS EC2. That's the exact workflow production teams use every day.
You've learned how to:
- Build a Flask REST API with proper project structure, health checks, and a production WSGI server (gunicorn).
- Containerize a multi-service application with Docker and Docker Compose, including environment variables and health checks.
- Create a GitHub Actions CI/CD pipeline that automatically tests and builds your Docker image on every push.
- Deploy to a live AWS EC2 instance accessible from anywhere, and add JWT authentication to protect API endpoints.
Ready to quiz yourself?