Build Local Microservices CI/CD
Deploy two local Docker services automatically with GitHub Actions.
Introduction
30 Second Summary
An app improvement only becomes useful when people can open the updated version. Repeating the same checks and restart steps by hand makes every update slower.
In this project, you will build two local HTTP services that communicate through a Docker Compose network. You will use a self-hosted GitHub Actions runner to turn each push to main into a tested deployment on your Windows PC for $0.
What You'll Build
Your finished demo turns a push to main into an updated version value at http://localhost:8080/ through a workflow running on your own Windows PC.
By the end of this project, you'll have:
- A live service response at http://localhost:8080/ that includes profile data fetched from a separate internal service.
- A healthy local deployment where the gateway reaches the profile service by its stable service name.
- A private push-driven workflow that turns each main update into a green deployment run on your PC.
- Secret Mission: Turn the smoke test into a response-contract gate that blocks the workflow when the profile version is wrong.
Are there any prerequisites?
You need a Windows PC with internet access plus enough local resources for two small Linux containers. Your starting setup includes Docker Desktop, Visual Studio Code, and a GitHub account.
Before We Start
Before any hands-on work begins, lock in the strictly $0 local microservices deployment you are about to build. A push to main will trigger your Windows GitHub Actions self-hosted runner to validate, build, test, then redeploy the local services without cloud infrastructure costs.
Prepare GitHub and Your Local Runner
A push cannot start your deployment until the project has a trusted source. It also needs a machine ready to receive the job.
A private GitHub repository supplies the source. A GitHub Actions self-hosted runner performs the work through Docker Desktop on your Windows PC.
This setup keeps the deployment local. GitHub Actions usage is free for self-hosted runners.
In this step, get ready to:
- Prepare Git on your Windows PC.
- Create the matching repository pair.
- Connect an interactive self-hosted runner.
Install Git and validate your tools
Git records the author behind each change before sending that history to GitHub. You need the current Git for Windows release so later repository commands behave consistently.
- Press the Windows key to open Windows search.
- Type PowerShell and press Enter to open Windows PowerShell.
- Check your installed Git version by running this command:
git --version
What does this check do?
The command asks Git to print its installed version. Your result determines whether Git is ready or needs an update.
✔️ I see version 2.56.0 or higher
Good. Git is installed at the required version.
ⓧ I see an older version
Your current Git installation needs an update before you initialize the project.
- Download Git for Windows 2.56.0(3) from the official Git for Windows page.
- Run the downloaded installer.
- Approve the Windows permission request if it appears.
- Keep the installer defaults.
- Close the current PowerShell window after installation.
- Use Windows search to open a new Windows PowerShell window.
ⓧ Command not found
Windows cannot find Git because Git for Windows is not installed or is unavailable in this PowerShell session.
- Download Git for Windows 2.56.0(3) from the official Git for Windows page.
- Run the downloaded installer.
- Approve the Windows permission request if it appears.
- Keep the installer defaults.
- Close the current PowerShell window after installation.
- Use Windows search to open a new Windows PowerShell window.
Git attaches an author name plus an email address to every commit. Configure the identity you want to use across repositories on this Windows PC.
- Set your global Git author identity by running these commands:
git config --global user.name "[[YOUR_NAME=\"your name\"]]"
git config --global user.email "[[YOUR_EMAIL=\"your commit email\"]]"
What do these commands configure?
- The first command stores the author name that Git adds to your commits.
- The second command stores the commit email associated with your work.
- The global setting applies this identity to repositories created by your Windows user.
PowerShell returns to its prompt without an error when both identity settings are accepted.
Git identity command failing?
Confirm that each value stays inside double quotes. This matters when your name contains spaces.
If PowerShell still cannot find Git, close that window after installation. Open a new PowerShell window so it receives the updated command path.
Ask for help with configuring your Git author identity.
Docker Desktop can take a minute to start its local engine. A short wait here is normal.
- Press the Windows key to open Windows search.
- Type Docker Desktop and press Enter to start it.
Before you run the checks, do you expect all three tools to return version information?
- Verify Git plus both Docker interfaces by running these commands:
git --version
docker version
docker compose version
What do these checks prove?
- The git --version check confirms that PowerShell can reach Git.
- The docker version check confirms that the Docker command can reach the running Docker Desktop engine.
- The docker compose version check confirms that Docker Compose is available for the local services.
You should see version information from Git. You should also see version information from Docker plus Docker Compose.
One of the version checks failing?
If a Docker check cannot reach the engine, wait for Docker Desktop to finish starting. Run the three checks again afterward.
If only the Git check fails, reopen PowerShell after completing the Git installer.
Ask for help with your local tool checks.
Create the repository pair
The deployment needs a private GitHub repository for trusted workflow events. A matching local folder gives you a place to build the project before you push it.
- Go to GitHub in your browser.
- Open GitHub's repository creation menu.
- Select New repository.
- Enter local-microservices-cicd as the repository name.
- Select Private as the repository visibility.
- Leave every repository initialization option unselected.
- Click Create repository.
You should see an empty private repository named local-microservices-cicd. Its page shows setup instructions because it contains no files or commits.
Why keep the repository private?
A self-hosted runner can execute workflow code on your Windows PC. A private repository limits who can submit that code.
This boundary protects the machine that runs Docker Desktop.
- Switch back to the PowerShell window from earlier.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The command moves PowerShell to your Desktop. The project folder you create next stays easy to find.
- Create the local project folder by running these commands:
mkdir local-microservices-cicd
cd local-microservices-cicd
What do these commands do?
- The first command creates the local-microservices-cicd folder on your Desktop.
- The second command moves PowerShell into that folder.
Your PowerShell prompt should now end with local-microservices-cicd.
Folder already exists?
If PowerShell reports that the folder already exists, confirm that it is the empty folder for this project. Move into it with the second command.
Ask for help with finding your local project folder.
- Initialize the local repository on the main branch by running this command:
git init -b main
What does this command do?
Git creates its repository metadata inside local-microservices-cicd. The -b main option names the initial branch main.
You should see confirmation that Git initialized an empty repository. The local repository has no application files yet.
Repository initialization failing?
Check that the PowerShell prompt ends with local-microservices-cicd before retrying the initialization command.
If Git is unavailable, return to the version check from the first substep.
Ask for help with initializing the main branch.
Connect the self-hosted runner
The self-hosted runner connects repository events to your Windows PC. Keeping it interactive lets you see when it is ready to receive a job.
- Return to the private local-microservices-cicd repository in your browser.
- Click Settings in the repository navigation.
- Select Actions in the settings sidebar.
- Select Runners.
- Click New self-hosted runner.
- Select Windows as the operating system.
- Select x64 as the architecture.
GitHub now generates the download plus configuration instructions for this repository. Those commands include the repository-specific details the runner needs.
Protect the registration token
This setup includes a temporary registration token. Keep the page private while you connect the runner.
- Press the Windows key to open Windows search.
- Type PowerShell and press Enter to open a second Windows PowerShell window.
- Run the generated download commands one at a time in the second PowerShell window.
- Run the generated configuration command in the same window.
- Accept the displayed default values during runner configuration.
- Choose the interactive option if configuration asks about running the runner as a Windows service.
The download can take a moment because GitHub is placing the runner software on your PC. Keep Docker Desktop running under the same Windows user.
Runner configuration failing?
Return to the runner setup page if the registration token has expired. GitHub generates current repository-specific setup instructions there.
Confirm that the selected platform is Windows with the x64 architecture.
Ask for help with registering your Windows runner.
Before you start the runner, do you think GitHub can send it a job while its terminal is closed?
- Run the final interactive start instruction shown on the GitHub setup page.
You should see Listening for Jobs in the runner terminal. This confirms that the local runner is connected and waiting.
- Keep the runner PowerShell window open.
- Refresh the Runners page in GitHub.
You should see the Windows x64 runner listed as idle. That is the hard part done: your private repository now has a local worker ready to receive deployment jobs.
Runner not showing as idle?
Check that the runner PowerShell window still shows Listening for Jobs. Restart the interactive runner from its installation location if the window was closed.
Confirm that you registered the runner from the private local-microservices-cicd repository's settings.
Ask for help with an offline self-hosted runner.
✔️ Awesome, I've got everything!
Your trusted source repository and local deployment runner are ready. Keep the runner PowerShell window open for the later workflow.
ⓧ I'd like to double check the full code
Your completed setup should match these three artifact states:
- A local repository exists in the local-microservices-cicd folder. It is initialized with the main branch plus your configured author identity.
- A Windows x64 runner is registered to the private local-microservices-cicd repository. It is running interactively and listening for jobs.
- A private empty GitHub repository named local-microservices-cicd exists.
Your trusted source plus local worker are ready. Next up, you will package the first visible service into a container.
Containerize the Gateway
Your local Git repository is ready for application code. Your GitHub Actions self-hosted runner is still listening for future jobs.
The gateway gives you an early browser-visible result. It also creates the service boundary that the profile container will challenge later.
You will write one HTTP endpoint. You will package it as a Docker image that publishes through a host-only port.
In this step, get ready to:
- Create a temporary JSON gateway service.
- Package the gateway in a pinned Python image.
- Verify the gateway through a local browser request.
Create the temporary gateway service
Python's built-in http.server can serve a small response without third-party dependencies. The container supplies Python to the service.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search box.
- Press Enter to start Visual Studio Code.
- Click File in the top menu.
- Select Open Folder....
- Choose the existing local-microservices-cicd folder.
- Confirm your folder selection.
You should see local-microservices-cicd in the Explorer view. This confirms that new files will land inside your local Git repository.
- Use the Explorer toolbar's New Folder control to create gateway-service.
- Select the new gateway-service folder in the Explorer view.
- Use the Explorer toolbar's new-file control to create server.py.
- Fill server.py with the temporary gateway by replacing the empty file with this code:
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = "0.0.0.0"
PORT = 8000
class GatewayHandler(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({"service": "gateway-service"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), GatewayHandler)
print(f"gateway-service listening on port {PORT}")
server.serve_forever()
What does this code do?
- The imports provide JSON encoding plus Python's built-in HTTP server classes.
- The HOST value lets the service accept traffic from the container's network interfaces.
- The PORT value makes the service listen on container port 8000.
- The do_GET() method returns a static response that identifies gateway-service.
- The ThreadingHTTPServer keeps the process running so the container can answer browser requests.
- Save server.py by pressing Ctrl+S.
- Confirm that server.py remains listed inside gateway-service in the Explorer view.
The editor should now show the complete temporary gateway. The file is ready to be copied into an image.
Can't find server.py?
- Check that server.py appears inside gateway-service instead of beside that folder.
- Check that the filename ends with .py.
- Use the Explorer view to move the file into gateway-service if it was created in the wrong location.
- Help me check the location and contents of my gateway-service/server.py file.
Package the gateway in a Docker image
A Dockerfile is the recipe Docker uses to build an image. This recipe starts from python:3.15.1-slim-trixie so the gateway gets a pinned Python runtime.
- Select the gateway-service folder in the Explorer view.
- Use the Explorer toolbar's new-file control to create Dockerfile with no file extension.
- Fill Dockerfile with the image recipe by replacing the empty file with this code:
FROM python:3.15.1-slim-trixie
WORKDIR /app
COPY server.py .
EXPOSE 8000
CMD ["python", "server.py"]
How does this image recipe work?
- The FROM instruction supplies the pinned Python runtime.
- The WORKDIR instruction makes /app the location for later instructions.
- The COPY instruction places server.py inside the image.
- The EXPOSE instruction documents the gateway's container port. Port publishing happens when you run the container.
- The CMD instruction starts the gateway whenever a container runs from this image.
- Save Dockerfile by pressing Ctrl+S.
- Confirm that the Explorer view lists Dockerfile beside server.py inside gateway-service.
Your image recipe and gateway source now share the same build context. Docker can copy the source file when it builds the image.
Dockerfile showing a file extension?
- Rename the file to exactly Dockerfile if Visual Studio Code shows an extension.
- Keep Dockerfile inside the same gateway-service folder as server.py.
- Help me fix the filenames and locations in my gateway-service folder.
✔️ Awesome, I've got everything!
Great. Double-check that you saved both gateway files.
ⓧ I'd like to double check the full code
Compare both files with these complete versions.
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = "0.0.0.0"
PORT = 8000
class GatewayHandler(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({"service": "gateway-service"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), GatewayHandler)
print(f"gateway-service listening on port {PORT}")
server.serve_forever()
What should server.py contain?
This complete file defines the temporary static gateway. It returns the gateway service name for every browser request.
FROM python:3.15.1-slim-trixie
WORKDIR /app
COPY server.py .
EXPOSE 8000
CMD ["python", "server.py"]
What should the Dockerfile contain?
This complete file packages the saved server with the pinned Python image. Its default command starts the gateway.
Build and test the gateway container
An image becomes a running container when Docker starts it. Port publishing connects the container's private port to an address on your Windows PC.
Binding the host side to 127.0.0.1:8080 keeps this demo reachable only from your computer. Requests arriving there travel to port 8000 inside the container.
- Click Terminal in the Visual Studio Code top menu.
- Select New Terminal.
The integrated terminal should open in local-microservices-cicd with Windows PowerShell ready for commands.
The first build can sit quietly while Docker downloads the Python base image.
- Build the local gateway image by running this command:
docker build --tag gateway-service:local gateway-service
What does this command do?
- The docker build command reads the Dockerfile from the gateway-service build context.
- The --tag option names the resulting image gateway-service:local.
The build output should finish successfully. The image is now available to run on your local Docker engine.
Gateway image not building?
- Check that Docker Desktop is still running.
- Check that the terminal path ends with local-microservices-cicd.
- Check that gateway-service contains both saved files.
- Help me troubleshoot my gateway-service Docker image build.
- Start the gateway in a detached temporary container by running this command:
docker run --detach --name gateway-service-demo --publish 127.0.0.1:8080:8000 gateway-service:local
How does the container start?
- The --detach option keeps the container running in the background.
- The --name option assigns the temporary name gateway-service-demo.
- The --publish option maps 127.0.0.1:8080 on Windows to port 8000 inside the container.
- The final argument selects the gateway-service:local image.
PowerShell should print a long container identifier. That identifier confirms Docker started the temporary container.
Before you open the gateway, what response do you expect from the endpoint?
- Switch back to the browser you used for GitHub.
- Create a new browser tab.
- Click the browser address bar.
- Enter http://localhost:8080/.
- Press Enter.
You should see {"service": "gateway-service"} in the browser. This proves that Windows can reach the Python service running inside the container.
That is your first running service complete. The gateway now has a repeatable image plus a browser-visible endpoint.
Gateway response not loading?
- Check that the browser address uses port 8080.
- Check the PowerShell terminal for the container identifier printed by the run command.
- Check that Docker Desktop still shows its engine as running.
- Help me troubleshoot why my gateway container is not responding at localhost:8080.
Removing this temporary container is safe. The gateway-service:local image stays on your computer for the next step.
- Return to the integrated PowerShell terminal in Visual Studio Code.
- Remove the temporary gateway container by running this command:
docker rm --force gateway-service-demo
What does this cleanup remove?
The command force-removes the running gateway-service-demo container. It leaves the gateway-service:local image ready for reuse.
PowerShell should echo gateway-service-demo. This confirms that no temporary gateway container remains.
Container name not found?
The container may already be removed if an earlier attempt cleaned it up. Your saved image and source files remain available.
Help me confirm whether my temporary gateway container has already been removed.
Your gateway is now packaged as a reusable local image. Next, you will add a profile service and test what happens when the gateway tries to reach it.
Expose the Container Networking Problem
Your gateway already proves that one Docker container can answer requests from your Windows PC. The application now needs live data from a second service.
This step adds that service behind a separate boundary. You will run both services independently to test whether the gateway can reach the profile endpoint through its default address.
In this step, get ready to:
- Create the profile service with health and profile endpoints.
- Upgrade the gateway to request live profile data.
- Run both containers separately to expose the networking problem.
Create the profile service
The profile service owns the internal profile response. Its separate HTTP server creates a real service boundary for the gateway to cross.
- Return to the Visual Studio Code window containing the local-microservices-cicd folder.
- Right-click the local-microservices-cicd folder in the Explorer view.
- Select New Folder.
- Enter profile-service.
- Press Enter.
You should see profile-service beside gateway-service in the Explorer view.
- Select the profile-service folder in the Explorer view.
- Select New File.
- Enter server.py.
- Build the first half of the profile server by pasting this code into profile-service/server.py:
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = "0.0.0.0"
PORT = 8001
class ProfileHandler(BaseHTTPRequestHandler):
def send_json(self, status_code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
What does this section do?
- The imports provide the threaded web server plus JSON encoding.
- The HOST value accepts requests through the container network.
- The PORT value keeps the profile service on container port 8001.
- The send_json() helper sends a status code with a JSON response body.
- Complete the profile server by pasting this code directly below the existing code:
def do_GET(self):
if self.path == "/health":
self.send_json(200, {"service": "profile-service", "status": "ready"})
return
if self.path == "/profile":
self.send_json(
200,
{
"service": "profile-service",
"status": "ready",
"version": "v1",
},
)
return
self.send_json(404, {"error": "not found"})
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), ProfileHandler)
print(f"profile-service listening on port {PORT}")
server.serve_forever()
How does the profile service respond?
- The /health endpoint reports that the service is ready.
- The /profile endpoint returns the service name.
- The profile response includes the readiness status.
- The version field identifies this response contract as v1.
- Save profile-service/server.py.
Seeing Python warnings in the editor?
Confirm that the second code section sits inside ProfileHandler with four leading spaces. Check that the final server block begins at the far left.
Use this if the indentation still looks wrong: Help me check the indentation in my profile-service/server.py file.
- Select the profile-service folder in the Explorer view.
- Select New File.
- Enter Dockerfile.
- Package the profile server by pasting this code into profile-service/Dockerfile:
FROM python:3.15.1-slim-trixie
WORKDIR /app
COPY server.py .
EXPOSE 8001
CMD ["python", "server.py"]
What does this Dockerfile do?
The file uses the pinned python:3.15.1-slim-trixie image. It copies the profile server into /app.
The image documents port 8001. Its default command starts server.py inside the container.
- Save profile-service/Dockerfile.
- Select View in the Visual Studio Code menu bar.
- Select Terminal.
- Build the profile service image from the local-microservices-cicd folder by running this command:
docker build --tag profile-service:local ./profile-service
What does this build create?
The command uses profile-service as the build context. It saves the resulting image locally as profile-service:local.
You should see the build complete without an error. The local profile image is now ready to run.
Profile image not building?
- Confirm that the terminal is inside the local-microservices-cicd folder.
- Confirm that profile-service contains both server.py and Dockerfile.
- Use this if the build still fails: Help me troubleshoot my profile-service Docker build.
Upgrade the gateway to fetch profile data
The gateway now needs an address for the profile endpoint. PROFILE_SERVICE_URL provides that address through an environment value with a local default.
- Select gateway-service/server.py in the Explorer view.
- Replace its existing contents with this first section:
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import URLError
from urllib.request import urlopen
HOST = "0.0.0.0"
PORT = 8000
PROFILE_SERVICE_URL = os.getenv(
"PROFILE_SERVICE_URL",
"http://localhost:8001/profile",
)
class GatewayHandler(BaseHTTPRequestHandler):
def send_json(self, status_code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self.send_json(200, {"service": "gateway-service", "status": "ready"})
return
if self.path != "/":
self.send_json(404, {"error": "not found"})
return
What changes in the gateway?
- The os import lets the gateway read PROFILE_SERVICE_URL from its environment.
- The urlopen import gives the gateway an HTTP client.
- The URLError import lets the gateway handle connection failures.
- The route checks provide a health response plus a clear response for unknown paths.
- Complete the gateway by pasting this code directly below the first section:
try:
with urlopen(PROFILE_SERVICE_URL, timeout=2) as response:
profile = json.loads(response.read().decode("utf-8"))
self.send_json(
200,
{
"service": "gateway-service",
"message": "Microservices are connected",
"profile": profile,
},
)
except (URLError, TimeoutError, json.JSONDecodeError) as error:
self.send_json(
502,
{
"service": "gateway-service",
"error": "profile service unavailable",
"detail": str(error),
},
)
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), GatewayHandler)
print(f"gateway-service listening on port {PORT}")
server.serve_forever()
How does the gateway use the profile?
- The gateway gives the profile request two seconds to respond.
- A successful response becomes the nested profile value returned by the gateway.
- Connection failures become an HTTP 502 response.
- The error response includes profile service unavailable so the failure is visible to the caller.
- Save gateway-service/server.py.
- Rebuild the local gateway image with the final source by running this command:
docker build --tag gateway-service:local ./gateway-service
Why rebuild the image?
The existing gateway-service:local image still contains the temporary static gateway. Rebuilding copies the new server.py into a replacement image with the same local tag.
You should see the build complete without an error. Both local images now contain the final service code.
Gateway image not rebuilding?
- Confirm that gateway-service/server.py contains both pasted sections.
- Confirm that gateway-service/Dockerfile still sits beside server.py.
- Use this if the build still fails: Help me troubleshoot my gateway-service Docker build.
Run both services without a shared network
The two images are ready for a direct connectivity test. Running them as separate standalone containers reproduces the assumption the gateway currently makes.
- Start the profile container separately by running the first command below:
- Start the gateway container separately by running the second command below:
docker run --detach --name profile-service-demo profile-service:local
docker run --detach --name gateway-service-demo --publish 127.0.0.1:8080:8000 gateway-service:local
What does this test set up?
Each command creates a standalone container. No shared user-defined network connects them.
Only the gateway publishes a port to your Windows PC. The binding keeps the gateway available at 127.0.0.1:8080.
You should see one container ID after each command. Both services are now running independently.
Before you check the gateway, do you expect its profile request to succeed or fail?
- Return to the browser tab from the earlier gateway test.
- Enter http://localhost:8080/ in the address bar.
- Press Enter.
You will see JSON containing profile service unavailable. The gateway sends that response with HTTP status 502.
Why does localhost fail here?
Inside the gateway container, localhost points back to the gateway container itself. The request to port 8001 never reaches the separate profile container.
You found the networking wall on purpose. The 502 response proves that a service boundary also needs a route between containers.
- Remove both temporary containers by running this command:
docker rm --force profile-service-demo gateway-service-demo
What does this cleanup remove?
The command stops both demonstration containers. It removes their container records while keeping profile-service:local plus gateway-service:local available for the next step.
You should see both container names in the terminal output. Port 8080 is now free for the Compose stack.
Containers not starting or cleaning up?
- Confirm that Docker Desktop is running before starting the containers again.
- Confirm that no other container is using host port 8080.
- Use this if the standalone test still fails unexpectedly: Help me troubleshoot my two standalone Docker containers.
✔️ Awesome, I've got everything!
Great work. Both images contain the final code.
Your standalone test exposed the container-local localhost problem. Both temporary containers are now removed.
ⓧ I'd like to double check the full code
Compare each project file with the complete versions below.
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import URLError
from urllib.request import urlopen
HOST = "0.0.0.0"
PORT = 8000
PROFILE_SERVICE_URL = os.getenv(
"PROFILE_SERVICE_URL",
"http://localhost:8001/profile",
)
class GatewayHandler(BaseHTTPRequestHandler):
def send_json(self, status_code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self.send_json(200, {"service": "gateway-service", "status": "ready"})
return
if self.path != "/":
self.send_json(404, {"error": "not found"})
return
try:
with urlopen(PROFILE_SERVICE_URL, timeout=2) as response:
profile = json.loads(response.read().decode("utf-8"))
self.send_json(
200,
{
"service": "gateway-service",
"message": "Microservices are connected",
"profile": profile,
},
)
except (URLError, TimeoutError, json.JSONDecodeError) as error:
self.send_json(
502,
{
"service": "gateway-service",
"error": "profile service unavailable",
"detail": str(error),
},
)
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), GatewayHandler)
print(f"gateway-service listening on port {PORT}")
server.serve_forever()
Gateway source checkpoint
Confirm that the gateway has the environment-based profile URL. Check that the error path returns status 502.
FROM python:3.15.1-slim-trixie
WORKDIR /app
COPY server.py .
EXPOSE 8000
CMD ["python", "server.py"]
Gateway image checkpoint
Confirm that the gateway image copies server.py into /app. Check that it documents port 8000.
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = "0.0.0.0"
PORT = 8001
class ProfileHandler(BaseHTTPRequestHandler):
def send_json(self, status_code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self.send_json(200, {"service": "profile-service", "status": "ready"})
return
if self.path == "/profile":
self.send_json(
200,
{
"service": "profile-service",
"status": "ready",
"version": "v1",
},
)
return
self.send_json(404, {"error": "not found"})
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), ProfileHandler)
print(f"profile-service listening on port {PORT}")
server.serve_forever()
Profile source checkpoint
Confirm that the profile service exposes /health plus /profile. Check that the profile version is v1.
FROM python:3.15.1-slim-trixie
WORKDIR /app
COPY server.py .
EXPOSE 8001
CMD ["python", "server.py"]
Profile image checkpoint
Confirm that the profile image copies server.py into /app. Check that it documents port 8001.
You now have two independently containerized services plus proof that the gateway cannot find its dependency through container-local localhost. Next, Docker Compose will give them a shared network with a stable service address.
Connect Services with Docker Compose
The standalone containers exposed the exact boundary you needed to see. Inside the gateway container, localhost points back to the gateway.
Docker Compose places both services on one bridge network. Service discovery lets the gateway reach profile-service through a stable name.
In this step, get ready to:
- Define both services on a private bridge network.
- Add health checks that coordinate service startup.
- Prove the gateway returns live profile data before stopping the stack.
Define the profile service network
A Compose file describes the services that belong in one application. The first section gives the profile service a build context plus an internal readiness check.
Why Docker Compose here?
Docker Compose keeps this project focused on service boundaries plus local networking. A local Kubernetes path adds cluster setup plus extra image-loading work on Windows.
- Switch back to Visual Studio Code from the previous step.
- Create compose.yaml directly inside the local-microservices-cicd folder with the file creation control in the Explorer sidebar.
- Define the profile service plus its network by pasting this configuration:
services:
profile-service:
build:
context: ./profile-service
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://localhost:8001/health', timeout=2)"
interval: 2s
timeout: 3s
retries: 10
start_period: 2s
networks:
- app-network
networks:
app-network:
driver: bridge
What Does This Configuration Do?
- The build context points Compose to the existing profile-service folder.
- The HTTP health check calls /health from inside the profile container.
- The retry settings give the profile service time to become ready.
- The app-network entry places the service on the application network.
- Save compose.yaml.
- Validate the first Compose model in the project PowerShell terminal by running:
docker compose config --quiet
What Does This Command Prove?
Compose parses the configuration without printing the expanded model. A valid file returns you to the PowerShell prompt without an error.
Good progress. The profile service plus its application network now pass Compose validation.
Configuration Not Validating?
- Confirm that compose.yaml sits directly inside local-microservices-cicd.
- Compare the indentation beneath services with the example.
- Confirm that Docker Desktop is running.
- Help me fix my Compose validation failure.
Add gateway discovery and healthy startup
The gateway needs the profile service name inside PROFILE_SERVICE_URL. Its dependency also keeps the gateway waiting until the profile service is healthy.
- Find the top-level networks: line in compose.yaml.
- Place your cursor on the blank line immediately before that block.
- Add the gateway service by pasting this configuration:
gateway-service:
build:
context: ./gateway-service
environment:
PROFILE_SERVICE_URL: http://profile-service:8001/profile
depends_on:
profile-service:
condition: service_healthy
ports:
- "127.0.0.1:8080:8000"
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)"
interval: 2s
timeout: 3s
retries: 10
start_period: 2s
networks:
- app-network
How Does This Fix the Connection?
- The PROFILE_SERVICE_URL value replaces container-local localhost with the service name profile-service.
- The service_healthy condition keeps the gateway waiting for a ready profile service.
- The gateway publishes container port 8000 through Windows port 8080.
- The profile service has no host port mapping.
- Save compose.yaml.
- Validate the complete Compose model in the project PowerShell terminal by running:
docker compose config --quiet
What Does the Complete Check Cover?
Compose checks both build paths plus every network reference. It also checks the dependency plus health-check structure.
The quiet result confirms that the complete two-service model is valid.
Still Seeing a Validation Problem?
- Confirm that gateway-service is indented beneath services.
- Confirm that the final networks block starts at the far left.
- Compare both build context names with the service folders in the Explorer sidebar.
- Help me diagnose my complete Compose model.
✔️ Awesome, I've got everything!
Great. Double-check that you saved compose.yaml after its successful validation.
ⓧ I'd like to double check the full code
Your complete compose.yaml file should match this reference exactly.
services:
profile-service:
build:
context: ./profile-service
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://localhost:8001/health', timeout=2)"
interval: 2s
timeout: 3s
retries: 10
start_period: 2s
networks:
- app-network
gateway-service:
build:
context: ./gateway-service
environment:
PROFILE_SERVICE_URL: http://profile-service:8001/profile
depends_on:
profile-service:
condition: service_healthy
ports:
- "127.0.0.1:8080:8000"
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)"
interval: 2s
timeout: 3s
retries: 10
start_period: 2s
networks:
- app-network
networks:
app-network:
driver: bridge
Start and verify the connected stack
The complete model can rebuild both images from the repository. The profile health check gates gateway startup.
Docker prints build progress while it refreshes image layers. The command returns after both services reach their required state.
Before you run this, do you expect the gateway health check to pass with the profile service name in place?
- Build the images plus start the connected stack by running:
docker compose up --build --detach --wait
What Does This Command Do?
- The --build option rebuilds each service image.
- The --detach option leaves the services running in the background.
- The --wait option holds the command until the services are running or healthy.
You should return to the PowerShell prompt after both services become healthy.
Services Not Becoming Healthy?
- Confirm that Docker Desktop is still running.
- Check that Windows port 8080 is free.
- Compare both health endpoint paths with compose.yaml.
- Help me troubleshoot my unhealthy Compose services.
Before you check the page, which response fields would prove that the gateway crossed the Compose network?
- Open http://localhost:8080/ in your browser.
You should see JSON with message set to Microservices are connected.
The nested profile object should show status as ready. Its version value should be v1.
You cleared the networking wall. The gateway now returns live data from the separate profile container.
Stopping the stack removes its Compose containers plus network. Your source files plus local images remain available.
Before you stop the stack, which resources do you expect Compose to remove?
- Stop the tested Compose stack by running:
docker compose down
What Does This Command Remove?
Compose stops the two service containers. It removes those containers plus their application network.
Your source files remain inside local-microservices-cicd. Your locally built images remain available.
You should see both service containers stop. You should also see the Compose network removed.
Stack Not Stopping?
- Confirm that the project PowerShell terminal is using the local-microservices-cicd folder.
- Confirm that Docker Desktop remains running.
- Help me stop my Compose stack safely.
Your connected application is validated plus ready for automation. Next, GitHub Actions takes over the same deployment process after a push to main.
Deploy on Every Push
Your healthy Docker Compose application already proved that the gateway can reach the profile service. The stack is currently stopped after that successful test.
Manual deployments depend on you repeating the right commands. In this step, GitHub Actions turns each push to main into a CI/CD job on your self-hosted runner.
In this step, get ready to:
- Create a workflow that responds to pushes on the main branch.
- Automate Compose validation through a local deployment job.
- Push the project to trigger the workflow on your Windows runner.
Build the workflow foundation
A GitHub Actions workflow is a YAML file stored in .github/workflows. Its trigger decides which repository events start the automation.
- Return to Visual Studio Code from earlier.
- Select the local-microservices-cicd folder in the file sidebar.
- Create .github/workflows/local-cicd.yml inside the local-microservices-cicd folder by entering the nested path in the file creation control.
You will see an empty .github/workflows/local-cicd.yml file open in the editor.
Why Use Your Own Runner?
A hosted runner cannot reach Docker Desktop on your Windows PC. Your self-hosted runner receives the repository job on the same machine.
That local access lets the workflow deploy the Compose stack without cloud infrastructure.
- Define the workflow trigger and runner job by copying this code into .github/workflows/local-cicd.yml:
name: Local Microservices CI-CD
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: local-compose-deploy
cancel-in-progress: true
jobs:
build-test-deploy:
runs-on: self-hosted
defaults:
run:
shell: powershell
steps:
- name: Check out the repository
uses: actions/checkout@v7.0.1
What Does the Workflow Foundation Do?
- The push trigger starts the workflow when a commit reaches main.
- The workflow_dispatch trigger also makes a manual run available.
- The read-only permission limits the job to reading repository contents.
- The concurrency group prevents two local deployments from overlapping.
- The self-hosted label sends the job to your registered runner.
- The powershell setting selects the Windows shell for each command.
- The actions/checkout@v7.0.1 step places the project files in the runner workspace.
- Save .github/workflows/local-cicd.yml.
- Confirm that the file sidebar shows local-cicd.yml inside .github/workflows.
The workflow file now contains its triggers and self-hosted job configuration.
Workflow File Missing?
- Confirm that local-cicd.yml is inside the workflows folder.
- Confirm that workflows is inside the .github folder.
- Check that the filename ends with .yml.
- Help me verify the location of my workflow file.
Add validation and deployment
Checkout only gives the runner access to your files. The next workflow steps validate the Compose model before deploying both services.
A PowerShell smoke test then requests the gateway. Its assertion stops the job if the response identifies the wrong service.
- Append the deployment sequence below uses: actions/checkout@v7.0.1 by pasting this code:
- name: Validate the Compose model
run: docker compose config --quiet
- name: Build and deploy the services
run: docker compose up --build --detach --wait
- name: Smoke-test the gateway
run: |
$response = Invoke-RestMethod -Uri "http://localhost:8080/" -Method Get
if ($response.service -ne "gateway-service") {
throw "Unexpected gateway response"
}
Write-Host "Deployment succeeded: $($response.message)"
- name: Show service logs after a failure
if: failure()
run: docker compose logs --no-color
How Does the Deployment Job Work?
- The validation step checks compose.yaml before deployment begins.
- The deployment step rebuilds the service images.
- The deployment step starts the Compose stack in the background.
- The wait option holds the job until both services become healthy.
- The smoke test requests http://localhost:8080/ from the Windows runner.
- The assertion requires the response to identify gateway-service.
- The final step prints Compose logs when an earlier step fails.
- Save .github/workflows/local-cicd.yml.
- Confirm that the final workflow line contains run: docker compose logs --no-color.
Your saved workflow now covers checkout, validation, deployment, smoke testing, and failure logs.
Seeing Workflow Indentation Problems?
- Keep every workflow step aligned beneath steps.
- Keep the PowerShell lines indented beneath run: |.
- Use spaces throughout the YAML file.
- Help me check my workflow indentation.
✔️ Awesome, I've got everything!
Great. Double-check that you saved .github/workflows/local-cicd.yml.
ⓧ I'd like to double check the full code
Compare your saved .github/workflows/local-cicd.yml file with this complete reference.
name: Local Microservices CI-CD
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: local-compose-deploy
cancel-in-progress: true
jobs:
build-test-deploy:
runs-on: self-hosted
defaults:
run:
shell: powershell
steps:
- name: Check out the repository
uses: actions/checkout@v7.0.1
- name: Validate the Compose model
run: docker compose config --quiet
- name: Build and deploy the services
run: docker compose up --build --detach --wait
- name: Smoke-test the gateway
run: |
$response = Invoke-RestMethod -Uri "http://localhost:8080/" -Method Get
if ($response.service -ne "gateway-service") {
throw "Unexpected gateway response"
}
Write-Host "Deployment succeeded: $($response.message)"
- name: Show service logs after a failure
if: failure()
run: docker compose logs --no-color
Push the project and verify deployment
A Git commit records the project as a snapshot. Pushing that snapshot to GitHub creates the event that starts your workflow.
- Return to the browser tab showing your private local-microservices-cicd repository.
- Copy the repository's HTTPS URL from the setup area.
- Record the copied URL here: https://github.com/YOUR_USERNAME/local-microservices-cicd.git.
You now have the destination that the local repository needs for its first push.
- Return to Visual Studio Code.
- Create an integrated PowerShell terminal by using the terminal controls in the top menu.
You will see a PowerShell prompt inside the local-microservices-cicd folder.
- Stage the project files and create the first commit by running these commands:
git add .
git commit -m "Deploy local microservices"
What Does the Commit Record?
The first command stages the project files. The second command stores that snapshot with a descriptive message.
You will see a commit summary that includes the workflow file and the Compose application.
Commit Did Not Complete?
- Confirm that the terminal path ends with local-microservices-cicd.
- Check that your Git author name remains configured from the setup step.
- Check that your Git commit email remains configured from the setup step.
- Help me diagnose the failed commit.
- Connect the local repository to the private GitHub repository by running this command:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/local-microservices-cicd.git"]]
What Does Origin Mean?
The name origin identifies your private GitHub repository as the push destination. Git stores that connection in the local repository.
A successful command returns you to the PowerShell prompt without an error.
Remote Connection Rejected?
- Confirm that the copied URL belongs to the private local-microservices-cicd repository.
- Check that the URL ends with local-microservices-cicd.git.
- Help me diagnose the origin remote.
The first push may open a browser sign-in prompt. Approving it gives Git access to your private repository.
Before you push, predict whether the stopped Compose stack will remain stopped or become healthy.
- Push the main branch by running this command:
git push --set-upstream origin main
What Happens After the Push?
The push creates the repository event defined in your workflow. GitHub Actions assigns the job to your interactive runner.
The runner checks out the project. It validates the Compose model before starting both services.
You will see the terminal confirm that main was pushed to origin.
Push Did Not Start the Workflow?
- Confirm that the interactive runner PowerShell window remains open.
- Confirm that the pushed branch is named main.
- Check that .github/workflows/local-cicd.yml was included in the commit.
- Help me trace why the workflow did not start.
- Switch to the self-hosted runner PowerShell window.
You will see the runner accept the job. The window prints each workflow step as it runs.
- Return to the private repository in your browser.
- Select the Actions tab.
- Select Local Microservices CI-CD.
- Open the newest workflow run.
Docker may spend some time rebuilding the images. Continued workflow output shows that your runner is still working.
- Keep the workflow run open until the job turns green.
The green run confirms that the repository event validated and deployed your Compose application.
- Switch back to the self-hosted runner PowerShell window.
The terminal returns to Listening for Jobs after the deployment finishes.
Before you open the gateway, predict which profile version the workflow deployed.
- Open a new browser tab.
- Enter http://localhost:8080/ in the address bar.
You will see gateway JSON containing Microservices are connected.
The nested profile reports ready. Its deployed version is v1.
What Did the Smoke Test Prove?
The workflow reached the deployed gateway from your Windows runner. It confirmed that the response identifies gateway-service.
The green run proves that a push can validate and deploy your local Compose application without a hosted runner.
You have closed the automation loop. A push to main now produces a healthy local deployment.
Secret mission
Enforce the Service Contract
Strengthen your smoke test into a response-contract gate that checks every critical field returned by the gateway. You will introduce a deliberate version mismatch to prove that incorrect service data stops the deployment workflow.
Clean Up Your Resources
Clean Up Your Resources
This $0 setup uses your Windows PC plus a private GitHub repository. Choose one cleanup path based on whether you want to keep building, pause the local deployment, or remove every project resource.
Resources you used:
- A healthy local Docker Compose deployment available at http://localhost:8080/.
- Two local container images for the gateway service plus profile service.
- Two running containers for the gateway service plus profile service.
- One private bridge network connecting the services.
- One interactive self-hosted GitHub Actions runner registration.
- The local Git repository folder named local-microservices-cicd.
- The private local-microservices-cicd repository containing your workflow history.
Keep everything running
No action is needed. Choose this path if you want to keep demonstrating push-driven deployments or continue improving the service contract.
- Keep Docker Desktop running so the application remains available at http://localhost:8080/.
- Keep the runner's PowerShell window open so pushes can continue triggering local deployments.
- Keep the local local-microservices-cicd folder for future code changes.
- Keep the private repository so your final green run plus intentional red run remain available.
Pause - I'll come back to this later
Shut down the local deployment to free its container resources. Your source code plus workflow history remain ready for later.
- Stop the services from the local local-microservices-cicd folder by running this command in its integrated PowerShell terminal:
docker compose down
What Does This Command Do?
The command stops both services. It removes their containers.
It also removes the private bridge network. Your two local images remain available for a faster return.
- Confirm the terminal reports that the gateway container plus profile container were removed.
Compose Stack Still Running?
Check that the integrated terminal is inside the local-microservices-cicd folder containing compose.yaml. Run the command again from that folder.
Ask for help with pausing the Compose stack.
- Close the interactive runner PowerShell window to stop it from receiving jobs.
Your local deployment is safely paused. When you return, restart the runner from its installation folder before your next push.
Delete - I don't want to use this again
This path permanently removes the resources created for the project. Choose it only when you no longer need the demo or its workflow history.
- Remove the Compose stack plus its local images from the local-microservices-cicd folder by running this command in its integrated PowerShell terminal:
docker compose down --rmi local
What Gets Removed?
- The command stops the Compose services.
- It removes the gateway container plus profile container.
- It removes the private bridge network.
- It deletes the local images built for this project.
- Confirm the terminal reports that the service containers plus network were removed.
- Confirm the terminal reports that the locally built images were removed.
Cleanup Command Failing?
Check that Docker Desktop is running. Confirm that your integrated terminal is inside the folder containing compose.yaml.
Ask for help with removing the local Compose resources.
- Close the interactive runner PowerShell window.
- Return to the private local-microservices-cicd repository on GitHub.
- Select Settings in the repository navigation.
- Select Actions in the repository settings sidebar.
- Select Runners.
- Select your Windows x64 runner.
- Use the removal option on the runner page to unregister it.
- Follow the generated Windows removal instructions.
The runner registration is now removed. Your Windows PC can no longer receive jobs from this repository.
- Close Visual Studio Code to release the local project folder.
- Use the Windows file browser to locate the local-microservices-cicd folder.
- Select the local-microservices-cicd folder.
- Press Delete to remove the local repository.
Only the private GitHub repository remains.
- Return to the private local-microservices-cicd repository on GitHub.
- Select Settings in the repository navigation.
- Use the repository deletion control in Repository Settings to permanently delete the repository.
- Confirm the deletion with the repository name local-microservices-cicd.
That's the complete teardown. Every resource created for this project is now removed.
Nice Work!
Nice Work!
You did it! Your local microservices application now turns every push to main into a tested deployment on your Windows PC.
You've learned how to:
- Built separate gateway and profile container images. The browser now shows connected JSON through http://localhost:8080/.
- Diagnosed why container-local localhost could not reach another service. Fixed the connection with Docker Compose service-name discovery on a private bridge network.
- Connected your private repository to a self-hosted GitHub Actions runner. Each push to main now starts a local CI/CD workflow that validates the Compose model before deploying the tested application.
- Completed the optional Secret Mission by adding a contract-aware deployment gate. A deliberate v999 mismatch produced Unexpected profile version. Restoring v1 returned the workflow to green.
Ready to quiz yourself?