From GitHub Repository to Running Container
Your app runs in a container on your laptop, with the code and Dockerfile in a GitHub repository. Now it needs to run on a real server, on a real domain.
Here's what makes Docker different: you don't deploy code; you deploy an image. Build it once, and any server with Docker runs it with identical dependencies and behaviour. The server never installs Node.js or Python.
The path runs like this: GitHub -> GitHub Actions builds the image -> ghcr.io stores it -> your VPS pulls and runs it -> Nginx puts it on your domain. For the wider picture, start with how to deploy an app from GitHub to a VPS.
Key Takeaways: How to Deploy a Docker App from GitHub
- You can deploy a Docker app from GitHub by building the image in a GitHub Actions workflow, pushing it to a Docker registry such as ghcr.io, and pulling it onto a Linux VPS.
- GitHub Container Registry (ghcr.io) authenticates with the built-in
GITHUB_TOKEN; no Docker Hub account needed. - Docker Compose runs the container, and a restart policy keeps it running after crashes or reboots; PM2 isn't needed.
- Pass environment variables and secrets to the container at runtime, never baked into the image.
- Nginx sits in front of the container as a reverse proxy for the domain and HTTPS.
- Docker needs root access and a full Linux environment; a VPS fits better than shared hosting.
What You'll Need
- An app with a working Dockerfile in a GitHub repository
- A Linux VPS running Ubuntu or Debian, with SSH access and a sudo user
- A domain, if the app should be publicly reachable
Docker won't run on standard shared hosting; it needs root access and a full Linux kernel. If your frontend is a static React build, see how to deploy a React app from GitHub to a VPS.
Step 1: Check Your Dockerfile
This guide assumes a working Dockerfile. Before deploying, confirm three things.
-
It builds locally. Build on your own machine first. The full stop at the end tells Docker to use the current folder as the build context:
docker build -t my-app .If it fails here, it'll fail in GitHub Actions too, and the logs are easier to read locally.
-
The app listens on 0.0.0.0, not localhost. Inside a container, binding to localhost makes the app reachable only from within it. Docker can't route traffic there, and you get a "connection refused" error that looks like a networking problem.
-
You have a
.dockerignore. It works like.gitignoreand keeps files out of the build context:node_modules .git .envThis makes the image smaller and the build faster, but the
.envline matters most.
WARNING: Never copy a .env file or other secrets into the image. Anyone who can pull it may be able to read them, even if a later layer removes them. Pass secrets in at runtime.
Step 2: Prepare Your VPS
Connect over SSH:
ssh username@YOUR_VPS_IP
Install Docker. You need Docker Engine plus the Docker Compose plugin. Commands vary by distribution and change over time; follow Docker's official installation guide. HostAfrica also has a Docker VPS setup guide.
Confirm both are there:
docker --version
docker compose version
If docker compose version errors but docker --version works, you have Docker Engine without the Compose plugin, which Step 4 needs.
Let your user run Docker. Docker commands need sudo by default. To run them as your own user, add it to the docker group, then log out and back in:
sudo usermod -aG docker $USER
SECURITY TIP: Membership of the docker group is effectively root access: anyone in it can start a container that mounts the whole filesystem. Only add users you'd trust with sudo.
Install Nginx and Certbot. Nginx sits in front of the container, and Certbot adds HTTPS in Step 5:
sudo apt update
sudo apt install nginx certbot python3-certbot-nginx -y
Open the firewall. If UFW is active, allow SSH and web traffic, or Nginx can't receive requests and Certbot will fail:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
Step 3: Build and Push the Image With GitHub Actions
A GitHub Actions workflow builds your image on GitHub's machines every time you push, then stores it in GitHub Container Registry. The Docker build push action builds and pushes; your VPS never builds anything; it pulls a finished image.
Create .github/workflows/docker-publish.yml in your repository. YAML is indentation-sensitive; make sure your editor uses spaces, not tabs:
name: Build and push Docker image
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
What each block does:
| Block | What it does |
|---|---|
| The comment at the top | The Docker actions come from Docker, not GitHub, and are governed by separate terms of service, privacy policy, and support documentation. That's standard for third-party actions. |
on: push: branches: [main] | Runs the workflow on every push to main. |
env: | Sets the registry and image name once for later steps. |
runs-on: ubuntu-latest | The build runs on Ubuntu, on a fresh machine GitHub provides and discards. |
permissions: contents: read, packages: write | Lets the job read your code and push the image. The push is denied without packages: write. |
steps: - name: Checkout repository | Uses actions/checkout@v4 to copy your repository onto the runner. |
| Set up Docker Buildx | Uses docker/setup-buildx-action@v3 to prepare Docker's extended builder, which the build step needs. |
| Log in to the container registry | Passes registry: ${{ env.REGISTRY }}, username: ${{ github.actor }} and the automatic GITHUB_TOKEN, so the job can push to ghcr.io. |
The step with id: meta | Uses docker/metadata-action@v5 to generate the image's tags: main from the branch, plus a permanent sha- tag from the commit. |
| Build and push Docker image | Uses the Docker build push action (docker/build-push-action@v6) to build the image and push it to the Docker registry with those tags. |
Commit and push, then watch it run. The Actions tab shows the workflow executing, and the image then appears under Packages.
IMPORTANT: Action version tags like @v3, @v4, and @v6 move on over time. Check each action's GitHub page for its current major version.
Alternative: Build on the VPS
For a quick test or hobby project, you can skip the registry: clone the repository onto the server and run docker compose up -d --build. Fewer moving parts.
The trade-off is that every update builds on your server, using CPU and memory while the app serves traffic. The registry route keeps the server lean and gives you tagged images to roll back to.
Step 4: Run the Container With Docker Compose
If your package is private, log in to the registry on the VPS once:
docker login ghcr.io
Use your GitHub username and a personal access token with the read:packages scope as the password, not your GitHub password.
Create a folder for the app. Every docker compose command here runs from it:
mkdir -p ~/apps/my-app && cd ~/apps/my-app
Put the app's environment variables in a .env file there, then make it readable only by you:
nano .env
chmod 600 .env
Now create compose.yaml in the same folder. The image name must be all lowercase, even if your username or repository name isn't:
services:
app:
image: ghcr.io/username/repo-name:main
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:3000:3000"
volumes:
- ./data:/app/data
Four lines do the important work.
restart: unless-stopped does PM2's job: Docker restarts the container after a crash or reboot and keeps doing so unless you stop it.
env_file: .env passes configuration in at runtime from a file that lives only on the server; secrets never enter the image.
127.0.0.1:3000:3000 publishes the port on the loopback interface only; nothing outside the server reaches the container directly.
./data:/app/data is a volume. Data inside a container is lost when it's replaced, which happens on every update; a volume keeps it on the server. Use one for databases and uploads.
SECURITY TIP: That 127.0.0.1: prefix matters more than it looks. Written as just 3000:3000, Docker publishes the port on every interface and writes its own iptables rules, which can bypass a UFW rule you thought was blocking it.
Start it:
docker compose up -d
Then check it's running rather than having started and exited immediately:
docker compose ps
docker compose logs
Step 5: Add Nginx and HTTPS
The container listens on 127.0.0.1:3000, which nobody outside the server can reach. Nginx bridges that gap, the same pattern used when deploying a Next.js app to a VPS.
Create a server block at /etc/nginx/sites-available/my-app:
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Those forwarded headers tell your app the visitor's real IP address and whether the request used HTTPS; without them, every request looks like it came from Nginx. HostAfrica's guide to Nginx basics covers these files.
Enable the site, disable the default one, then test and reload:
sudo ln -s /etc/nginx/sites-available/my-app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Point your domain's DNS A records for example.com and www.example.com at the VPS (HostAfrica's guide to setting up DNS walks through the records), then add HTTPS once the domain resolves:
sudo certbot --nginx -d example.com -d www.example.com
Certbot gets a free Let's Encrypt certificate, serves HTTPS on port 443, redirects HTTP to HTTPS, and handles renewal. If you need a commercial certificate, HostAfrica sells DV, OV, and EV SSL certificates.
How Do You Update a Docker App?
Push your changes to main. The workflow builds and pushes a new image automatically. Then, on the VPS:
cd ~/apps/my-app
docker compose pull
docker compose up -d
pull fetches the new image, and up -d recreates the container from it. Docker replaces the container rather than patching it, which is why volumes matter.
Rolling back. Every build also gets a permanent sha- tag. Find it under Packages on GitHub, change the image: line in compose.yaml to it, for example ghcr.io/username/repo-name:sha-1a2b3c4, and run docker compose up -d. Switch back to :main once the fix is out.
Old images pile up and fill the disk; clean them out periodically. The -f flag skips the confirmation prompt:
docker image prune -f
QUICK TIP: Before a significant release, snapshot the VPS so you can roll back the whole server, not just the container. Snapshots are triggered through the HostAfrica API; you can script one as a pre-deployment step.
You can also automate the pull-and-restart step with a workflow that connects over SSH after the build, making deployment a single git push.
Common Docker Deployment Problems
| Problem | Likely cause | What to check |
|---|---|---|
| Denied when the workflow pushes | Missing packages: write | The permissions: block in the workflow |
| Denied when pulling on the VPS | Private package with no login | docker login ghcr.io with a read:packages token |
| Invalid reference format | Uppercase letters in the image name | The image: line must be all lowercase |
| Exec format error | Image built for a different CPU architecture | GitHub's runners build for x86-64. For an ARM VPS, add docker/setup-qemu-action and platforms: linux/amd64,linux/arm64 |
| Container exits immediately | The app crashed on startup | docker compose logs |
| Nginx returns 502 | App bound to localhost in the container, or wrong port | The bind address and the port mapping |
| Permission denied running docker | Your user isn't in the docker group | sudo usermod -aG docker $USER, then log in again |
| Certbot fails | Port 80 blocked, or DNS not propagated | Firewall rules; that the domain resolves |
| Data gone after an update | No volume | Volumes for databases and uploads |
Most of these have more than one possible cause, and docker compose logs answers a surprising number of them.
FAQ: How to Deploy a Docker App from GitHub
Do I need Docker Hub?
No. Docker Hub is only one Docker registry. GitHub Container Registry works with your existing GitHub account and authenticates with the automatic GITHUB_TOKEN, with nothing extra to sign up for.
Is GitHub Container Registry free?
Public packages are free. Private packages count toward your account's storage and transfer allowance, which varies by plan. Check GitHub's current billing documentation for the figures.
Do I need PM2 with Docker?
No. restart: unless-stopped in your Compose file does the same job: it restarts the container after a crash and brings it back after a reboot.
Can GitHub automatically deploy a Docker app?
Partly. The workflow builds the image and pushes it to the registry on every commit. You can also deploy to your server by adding a workflow step that connects over SSH after the build.
Can I run Docker on shared hosting?
Generally, no. Docker needs root access and a full Linux kernel, neither of which shared hosting provides. A VPS is the usual starting point.
Conclusion
The model is what makes this worth the setup. GitHub stores the code, GitHub Actions builds the image, ghcr.io stores it, your VPS pulls and runs it, Nginx makes it public. The image GitHub built is the one in production.
A HostAfrica Linux VPS suits this well: Docker needs root access and a full Linux environment, and one server can run several containers, a Compose stack, and Nginx. HostAfrica's Application Hosting offers a Docker template with Docker pre-installed; you keep full root access and remain responsible for the server.
