← Journal
September 30, 2026·8 min read·Nasywan

Hard-EarnedLessonsDeployingaFull-StackAppwithDockerandGitHubActions

Here is a breakdown of what I learned along the way about Docker internal networking, zero-collision containerization, automated CI/CD with GitHub Actions, and debugging real-world production quirks.

DockerGithub ActionCI/CDFullstack DeveloperTraefikDevops
Hard-Earned Lessons Deploying a Full-Stack App with Docker and GitHub Actions

Deploying a brand new web application to a live VPS is always an exciting milestone. But things get interesting when the server is not an empty canvas.

Recently, I had to deploy a full-stack project consisting of a Go backend and a Vue SPA frontend onto an active production server. The server was already running two other active applications with their own databases, redis instances, and reverse proxies.

The primary rule from day one was clear: do not break anything that is already running. No database resets, no docker prunes, and zero downtime for existing projects.

Here is a breakdown of what I learned along the way about Docker internal networking, zero-collision containerization, automated CI/CD with GitHub Actions, and debugging real-world production quirks.

Stop Exposing Ports You Do Not Need

When developing locally, it is common to map container ports directly to your host machine in `docker-compose.yml`. You map `5432:5432` for Postgres and `6379:6379` for Redis so you can inspect your database with tools like TablePlus or DBeaver.

When you push that configuration to a shared production VPS, disaster strikes immediately.

On our server, port 5432 was already occupied by another Postgres container, and port 5433 was taken by a second project. Attempting to bind port 5432 on the host would immediately crash the container with the dreaded `address already in use` error.

The solution is embracing Docker internal bridge networks.

Your backend container and database container only need to talk to each other. They do not need to expose database ports to the public internet or even to the host system. By keeping the database inside a private bridge network, Docker assigns an internal DNS name based on the service name. Your Go backend simply connects to `postgres:5432` through the virtual network.

No host port binding is needed, and you achieve complete isolation from existing databases on the server.

Let a Reverse Proxy Do the Heavy Lifting

Instead of exposing random host ports for every new web service, our VPS relies on Traefik as the single front-door reverse proxy on ports 80 and 443.

Traefik acts like an intelligent traffic controller. It listens to the Docker daemon socket and routes incoming requests based on the HTTP `Host` header.

By attaching our application containers to a shared `proxy_network` and declaring Traefik labels inside `docker-compose.yml`, we can hook up domain routing seamlessly:

  • A request to the API domain routes directly to the Go backend container.
  • A request to the main web domain routes directly to the Nginx frontend container.
  • Existing apps on the server continue working untouched.

Because Traefik was already configured with wildcard SSL certificates, adding `traefik.http.routers.my-app.tls=true` gave us instant, valid HTTPS out of the box without having to reconfigure Let's Encrypt certificates.

The Sneaky Alpine Linux IPv6 Healthcheck Trap

One of the most interesting bugs i ran into involved container healthchecks.

After getting the frontend container running with an Alpine-based Nginx image, visiting the website returned an immediate `404 page not found` from Traefik. The logs showed Nginx was running, so why was Traefik returning a 404?

A quick check with `docker ps` revealed the container status was `Up (unhealthy)`.

Traefik has a smart safety feature: if Docker reports a container as unhealthy, Traefik refuses to send public traffic to it and returns a 404.

The culprit was hidden inside the Dockerfile healthcheck command:

Dockerfile
1CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1

In Alpine Linux, `localhost` resolves to both IPv4 (`127.0.0.1`) and IPv6 (`::1`). Alpine's BusyBox `wget` tries the IPv6 address first. However, our default Nginx configuration was only listening on IPv4 (`listen 80;`).

The connection to `::1:80` was refused, `wget` exited with code 1, Docker marked the container as unhealthy, and Traefik blocked all traffic.

Overriding the healthcheck target to explicitly use IPv4 (`http://127.0.0.1/health`) fixed the issue instantly. The container turned healthy, and the website immediately appeared online.

Building Clean CI/CD Pipelines with GitHub Actions and GHCR

Automating deployments through GitHub Actions makes shipping code feel effortless, but organization level security requires careful planning.

Avoiding the Personal Access Token Trap

A common beginner anti-pattern is using an individual developer's Personal Access Token (PAT) on the production server to pull private images. If that developer ever leaves the company or resets their account tokens, production deployments break instantly.

Instead, GitHub Actions provides a built-in `GITHUB_TOKEN` for every workflow run. In our deployment workflow, GitHub Actions compiles the Docker image, pushes it to GitHub Container Registry (GHCR), and connects to the VPS via SSH.

Inside the SSH session, it logs into Docker using the ephemeral `GITHUB_TOKEN`, pulls the newly built image, runs database migrations, and updates the container without depending on anyone's personal credentials.

Mind Your Metadata Tags

I also hit a classic deployment hiccup where the image build passed, but the server failed with `failed to resolve reference ... not found`.

Our workflow was configured with:

deploy.yml
1tags: |
2type=raw,value=latest,enable={{is_default_branch}}

Because of branch naming discrepancies between `main` and `master`, the condition evaluated to false, skipping the creation of the `latest` tag entirely. Removing the conditional check ensured that every production release created a predictable `latest` tag for the server to pull.

Frontend vs Backend: Why .env Files Behave Differently

One of the biggest mental shifts when containerizing a full-stack project is understanding where configuration actually lives.

For the backend, code runs dynamically on the server. Keeping a `config.json` or `.env` file on the VPS storage and mounting it into the container is clean and secure. The database password remains on the server and is never baked into the container image.

For a frontend Single Page Application (SPA), the story is completely different.

Tools like Vite compile Vue or React code into static HTML, JavaScript, and CSS bundles during the build step. By the time Nginx serves those files, there is no Node.js runtime running. Every `import.meta.env.VITE_*` variable gets hardcoded into the JavaScript files while `vite build` runs inside GitHub Actions.

Never commit `.env` files with sensitive data to Git just to satisfy a build.

Instead, the proper enterprise approach is storing frontend configuration in GitHub Repository Variables (under Settings -> Secrets and variables -> Actions). During the GitHub Actions workflow, these variables are injected directly into Docker via `build-args`. The Git history stays clean, and you can update environment targets directly from GitHub's dashboard.

Watch Out for Client-Side Headers Breaking SSE

Once the app was live, i noticed our real-time AI generation progress stream failed to connect with a generic `net::ERR_FAILED` error in the browser console.

Looking closely at the network HAR logs, we found that our frontend `fetch` request was sending:

stream.tsts
1headers: {
2'Accept': 'text/event-stream',
3'Cache-Control': 'no-cache, no-transform',
4'X-Accel-Buffering': 'no'
5}

Headers like `X-Accel-Buffering: no` are response headers intended for web servers like Nginx to turn off proxy buffering for streaming data. They are not client request headers.

Because the browser sent non-standard custom headers in a cross-origin request, it triggered a CORS preflight (`OPTIONS`) request. Our backend CORS middleware only allowed standard headers, so the preflight was rejected, killing the Server-Sent Events stream before it even started.

Removing those two unnecessary request headers from the frontend allowed the browser to establish the stream without a preflight failure, while the backend handled streaming buffers properly on its end.

Final Thoughts

Modern web deployment is rarely just about running `docker run` and calling it a day.

Working through this setup highlighted how much power modern tools give us when used together:

  • Docker internal bridge networks keep your microservices secure and isolated from neighbors on the same VPS.
  • Traefik handles multi-domain reverse proxying and SSL termination without headaches.
  • GitHub Actions and GHCR allow automated, zero-touch deployments with clean secrets management.
  • Understanding the difference between server runtime config and static frontend compilation prevents hours of debugging.

Taking the time to build a non-destructive, automated pipeline pays dividends every single time you push a commit. Deployments become calm, predictable, and fast.

(Share)

(Next essay)

My Experience Applying to Apple Developer Academy →