When I started this project, the main intention was simple. I wanted a place to run my Spring Boot services — my config server, my logger, my events tracker — without paying a cloud bill every month just to keep a hobby project alive. In my last infrastructure post, I moved a config server from Elastic Beanstalk to Docker on a single EC2 instance and saved real money. This time I went one step further: I moved the whole stack off the cloud entirely, into a VirtualBox VM running on my desk, managed with Portainer.
The great thing for me was not just saving the ~$10/month. It was discovering that once your entire stack is a compose file in git and a set of images on Docker Hub, the host stops mattering. That realization is also my exit plan — when I need real cloud again, I plan to lift this same stack onto A Cloud Guru’s playground servers. More on that at the end.
In this post, I’ll walk you through:
– Why I chose VirtualBox + Portainer for the stack
– The CI/CD pipeline: GitHub Actions → Docker Hub → Watchtower
– The gotchas that cost me real hours (ARM64 vs amd64, healthchecks, ports)
– Why this design makes the move to A Cloud Guru almost boring
– What I learned
The Setup: A Datacenter on My Desk
The stack is five of my services plus infrastructure, all defined in one docker-compose file:
Your Mac (Apple Silicon / ARM64)
│
│ (git push to GitHub)
▼
GitHub Actions ──builds linux/amd64──▶ Docker Hub
│
│ (Watchtower polls every 5 min)
▼
VirtualBox VM — Bridged Adapter (linux/amd64)
├── Portainer :9000
├── config-server :8888
├── sathishlogger :8090
├── eventstracker :9081
├── postgres
├── rabbitmq :15672
└── watchtower
The VM uses a Bridged Adapter, so it gets its own IP on my home LAN. No port forwarding, no NAT rules. Every service is reachable at `http://<vm-ip>:<port>` from any machine in the house.
Portainer is the piece that made this feel like a real environment instead of a pile of `docker run` commands. I paste my compose file into a Portainer stack, set the environment variables in the Stacks → Environment tab, and hit deploy. When something breaks, the container logs are one click away. For a homelab, that UI is worth a lot.
The CI/CD Flow: No Inbound Ports, No Webhooks
This was the part I enjoyed designing the most. The obvious way to auto-deploy to a VM is a webhook — GitHub Actions calls your server, your server pulls the new image. But that means exposing an inbound port on my home network to the internet, and I did not want that.
The answer was Watchtower, running as a container inside the VM. Instead of the world pushing to me, Watchtower polls Docker Hub every 5 minutes and compares image digests. When GitHub Actions pushes a new :latest, Watchtower notices, pulls it, and does a rolling restart of just that container.
The build side is a single GitHub Actions workflow per service. Here’s the real one for the config server (.github/workflows/ci-cd.yml) — a build job that produces the JAR, and a docker job that only runs on direct pushes to main and publishes both a :latest and a :<sha> tag:
name: CI/CD → Docker Hubon: push: branches: [ main ] pull_request: branches: [ main ] workflow_dispatch:permissions: contents: readenv: IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/sathishproject-config-serverjobs: build: name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: "21" distribution: temurin cache: maven - name: Build (skip tests) run: ./mvnw package -DskipTests -B - name: Upload JAR artifact uses: actions/upload-artifact@v4 with: name: app-jar path: target/*.jar retention-days: 1 docker: name: Docker push runs-on: ubuntu-latest needs: build # Only push on direct pushes to main (not on PRs) if: github.event_name != 'pull_request' steps: - uses: actions/checkout@v4 - name: Download JAR artifact uses: actions/download-artifact@v4 with: name: app-jar path: target/ - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: | ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:${{ github.sha }} cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest cache-to: type=inline
So the full flow after a git push to main is:
- GitHub Actions builds the Maven JAR on
ubuntu-latest - The workflow builds and pushes
:<sha>and:latesttags to Docker Hub - Watchtower detects the new digest within 5 minutes
- Watchtower pulls and restarts the affected container
Push to git, wait five minutes, refresh the browser. No SSH, no webhook, no open inbound port. The pull-based model is slower than a webhook, but for a hobby project, “eventually within 5 minutes” is a perfectly good SLA.
The Gotchas That Cost Me Real Hours
Apple Silicon vs. the VM
My Mac is ARM64. The VirtualBox VM is linux/amd64. The first time I built an image on the Mac and pulled it in the VM, Portainer greeted me with:
no matching manifest for linux/amd64
Every local build now has to be docker buildx build --platform linux/amd64 --push. The --push matters too — buildx builds in a remote builder context, so the image never lands in your local daemon; it has to go straight to Docker Hub. The cleaner fix is to let GitHub Actions do all the building, since ubuntu-latest runners are amd64 anyway. The bootstrap build is the only time my Mac touches image creation.
Letting CI own the build also let me keep the runtime image tiny and architecture-neutral — the JAR is built upstream and the Dockerfile is runtime-only (eventstracker/Dockerfile):
# Runtime only — JAR is built by Maven on the host (or in CI) before docker build.# This avoids the sathish-projects-logger SNAPSHOT resolution issue inside Docker.FROM eclipse-temurin:21-jre-alpineRUN addgroup -S appgrp && adduser -S appuser -G appgrpWORKDIR /appCOPY target/app.jar app.jarUSER appuserEXPOSE 9081ENTRYPOINT ["java", "-jar", "app.jar"]
The Healthcheck That Lied
My eventstracker service depends on the config server being healthy before it starts. Simple, right? Use depends_on with condition: service_healthy and probe /health.
Except Spring Cloud Config Server’s /health endpoint can return 503 when the built-in configServer health component can’t load a default environment from git — even when the server is perfectly capable of serving configuration. My container was healthy and “unhealthy” at the same time. On top of that, all my actuator endpoints sit behind basic auth, so the probe has to send an Authorization header, and compose interpolation eats $ signs unless you escape them as $$:
healthcheck:
test: [ "CMD-SHELL", "wget -qO- --header=\"Authorization: Basic $$(echo -n $$username:$$pass | base64)\" http://localhost:8888/sathishconfigserver/info >/dev/null 2>&1 || exit 1" ]
start_period: 40s
The fix was to probe /info (a stable 200) instead of /health. The lesson: a healthcheck is a contract, and you need to understand what the endpoint actually reports before you build startup ordering on top of it.
The Port That Went Nowhere
Eventstracker kept giving me ERR_CONNECTION_REFUSED on port 9081 even though the container was running. The compose file published 9081:9081, but the app’s server.port was supposed to come from the config server’s git repo — and when it wasn’t there, Spring Boot quietly defaulted to 8080 inside the container. Host 9081 was mapped to container 9081, and nothing was listening on container 9081.
The config that’s supposed to pin it down lives in the config server’s git repo (eventstracker-prod.yml):
######## EventTracker Production Configuration #########server: port: 9081
When that file resolves, everything lines up. When it doesn’t, the safety net is one line in the compose environment:
environment: SERVER_PORT: 9081
When configuration is externalized, the failure mode moves too. The bug wasn’t in the code or the compose file individually — it was in the gap between them.
The Local SNAPSHOT Dependency
My eventstracker depends on sathish-projects-logger, a local SNAPSHOT that isn’t on Maven Central. Locally that means mvn install (not just package) in the logger repo first. In CI it means the eventstracker workflow clones the logger repo and installs it before its own build. Not elegant, but honest — and it’s documented in the workflow instead of living in my head.
# eventstracker depends on com.sathish:sathish-projects-logger as a local# SNAPSHOT, which is not on Maven Central. Build & install it first.- name: Install sathish-projects-logger dependency run: | git clone --depth 1 https://github.com/${{ github.repository_owner }}/sathish-projects-logger.git /tmp/sathish-projects-logger cd /tmp/sathish-projects-logger ./mvnw install -DskipTests -B || mvn install -DskipTests -B- name: Build eventstracker (skip tests) run: | ./mvnw package -DskipTests -B mv target/*.jar target/app.jar
The Exit Plan: A Cloud Guru
Here’s the part I’m most happy about. I’m preparing for more cloud certifications, and my Pluralsight/A Cloud Guru subscription comes with cloud playground servers and sandboxes — real environments, but with real constraints. Sandbox sessions run about four hours, and cloud servers stay alive only as long as you start them every 14 days.
At first that sounds like a dealbreaker for hosting anything. But look at what this stack actually is:
- The topology is a docker-compose file in git
- The images are on Docker Hub
- The application config is in a git repo, served by Spring Cloud Config
- The secrets are a short list of environment variables I set at deploy time
So the transition plan, when I need it, is deliberately boring:
- Spin up an ACG cloud server
- Install Docker and run the Portainer container
- Paste the same compose file into a new Portainer stack
- Set the same environment variables
- Watchtower resumes polling Docker Hub, and CI/CD works exactly as before
No image rebuilds (ACG servers are amd64, same as the VM — the GitHub Actions decision pays off again). No pipeline changes. The host is a variable, not a dependency. The VirtualBox VM was never the point; it was just the cheapest host that could run the design. When the session expires or the server gets reaped, I lose a host, not a system.
The honest trade-off: I do lose the data volumes. For study and demo scenarios that’s fine — the eventstracker seeds its initial user on startup, and I can add a pg_dump step if I ever care about the data. That’s a problem I’ll solve when it’s real, not before.
What I Learned
Pull-based deployment is underrated. Watchtower polling Docker Hub means zero inbound exposure on my home network. For a homelab, that security posture is hard to beat.
Build for the target architecture, always. The ARM64/amd64 mismatch is the tax every Apple Silicon developer pays. Letting CI own the builds makes the problem disappear.
Healthchecks are architecture, not decoration. My startup ordering depends entirely on that one wget probe. Understanding why /health returned 503 taught me more about Spring Cloud Config than the happy path ever did.
Portability is a discipline, not a feature. The stack can move to A Cloud Guru in an afternoon only because nothing important lives on the host. That was a design choice made on day one, and it has to be defended on every change.
A homelab teaches you the same lessons as the cloud, for free. Networking, startup ordering, secrets handling, deployment automation — the concepts transfer one-to-one. The VM on my desk is a rehearsal space for the certification labs ahead.
What’s Next
The VirtualBox stack is running well. Future improvements might include a pg_dump backup step before the ACG move, pinning image tags instead of :latest for more controlled rollouts, and trying the same compose file on an ACG sandbox as a dry run before I actually need it. I’ll write that post when it happens.
GitHub for the code is
