Deploying to several environments is safe only when every environment runs the exact same build and the only differences are configuration. In this tutorial you will build a Docker image once per commit, push it to GitHub Container Registry and promote it through dev, staging and production servers running Ubuntu 24.04 with GitHub Actions. Each environment gets its own secrets, and production waits for a manual approval.
Prerequisites
To follow this tutorial you need:
- Three servers running Ubuntu 24.04 LTS (for example, three CubePath VPS), one per environment, each with Docker Engine and the Docker Compose plugin installed. For testing, dev and staging can share a server if they use different directories and ports.
- On each server, a non-root
deployuser that belongs to thedockergroup. - A GitHub repository with a
Dockerfilefor your application. - For the approval gate: a public repository, or GitHub Enterprise for private repositories. Environments and environment secrets work on private repositories with GitHub Pro, Team or Enterprise, but required reviewers on private repositories need GitHub Enterprise.
Step 1 - Planning the promotion flow
The pipeline follows these rules:
| Environment | Triggered by | Gate | Configuration source |
|---|---|---|---|
| dev | Every push to main | None | dev environment secrets + server app.env |
| staging | Successful dev deploy | None | staging environment secrets + server app.env |
| production | Successful staging deploy | Manual approval | production environment secrets + server app.env |
Two principles keep the environments honest:
- Build once, deploy many times. The image is built in a single job and tagged with the commit SHA. Staging and production deploy that same tag, so what you tested is what you ship.
- Configuration lives outside the image. Hostnames, database URLs and API keys come from each server's environment file or from GitHub environment secrets, never from build arguments.
Step 2 - Preparing each server
Run these commands on each of the three servers. Create the application directory owned by the deploy user:
sudo mkdir -p /opt/myapp
sudo chown deploy:deploy /opt/myapp
Switch to the deploy user for the rest of this step:
sudo -iu deploy
Create the Compose file. It is identical on all servers; only the image tag and app.env change:
nano /opt/myapp/compose.yaml
services:
app:
image: ghcr.io/your_org/myapp:${APP_VERSION:?APP_VERSION is not set}
restart: unless-stopped
env_file: app.env
ports:
- "127.0.0.1:8000:8000"
Create the environment-specific settings. On the dev server, for example:
nano /opt/myapp/app.env
APP_ENV=dev
DATABASE_URL=postgres://myapp:[email protected]:5432/myapp_dev
LOG_LEVEL=debug
Use different values on staging and production, and restrict the file:
chmod 600 /opt/myapp/app.env
Keeping app.env on the server means production credentials never pass through the CI logs or runners.
If the image is private, log the server in to GitHub Container Registry once with a personal access token (classic) that has only the read:packages scope. Docker stores the credential in ~/.docker/config.json for the deploy user:
docker login ghcr.io -u your_github_user
Step 3 - Creating a deploy key for GitHub Actions
GitHub Actions will connect to the servers over SSH. On your local machine, generate one key pair per environment so that a leaked dev key cannot reach production:
ssh-keygen -t ed25519 -f ./deploy_dev -C "github-actions-dev" -N ""
ssh-keygen -t ed25519 -f ./deploy_staging -C "github-actions-staging" -N ""
ssh-keygen -t ed25519 -f ./deploy_production -C "github-actions-production" -N ""
Copy each public key to the deploy user on the matching server, for example:
ssh-copy-id -i ./deploy_dev.pub deploy@your_dev_server_ip
Capture each server's host key so the workflow can verify it instead of trusting any host:
ssh-keyscan -t ed25519 your_dev_server_ip
203.0.113.10 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
Keep that line; you will store it as a secret in the next step.
Step 4 - Creating the GitHub environments
In your repository, go to Settings > Environments and create three environments named dev, staging and production. For each one, add these environment secrets:
| Secret | Value |
|---|---|
SSH_HOST | The server's IP address or hostname |
SSH_PRIVATE_KEY | The content of the matching private key file, for example deploy_dev |
SSH_KNOWN_HOSTS | The ssh-keyscan line for that server |
Add an environment variable named APP_URL with the public URL of each environment (for example https://staging.your_domain). It is shown as a link on each deployment.
Then configure the protection rules for production:
- Enable Required reviewers and add the people or team who can approve production deploys.
- Optionally enable Prevent self-review so the person who pushed cannot approve their own deploy.
- Under Deployment branches and tags, choose Selected branches and tags and add
main, so no other branch can deploy to production.
Once the secrets are stored in GitHub, delete the private key files from your local machine.
Step 5 - Writing a reusable deploy workflow
The deploy steps are the same for every environment, so put them in a reusable workflow that receives the environment name and the image tag. Environment secrets are available in it because the job declares environment.
Create the file:
mkdir -p .github/workflows
nano .github/workflows/deploy.yml
name: deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
version:
required: true
type: string
jobs:
deploy:
runs-on: ubuntu-24.04
environment:
name: ${{ inputs.environment }}
url: ${{ vars.APP_URL }}
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
steps:
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Deploy ${{ inputs.version }}
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
VERSION: ${{ inputs.version }}
run: |
ssh "deploy@${SSH_HOST}" \
"cd /opt/myapp && export APP_VERSION='${VERSION}' && docker compose pull && docker compose up -d && docker compose ps"
- name: Smoke test
env:
APP_URL: ${{ vars.APP_URL }}
run: curl -fsS --retry 10 --retry-delay 3 --retry-all-errors "${APP_URL}/health"
The concurrency group guarantees that two deploys to the same environment never run at the same time, while cancel-in-progress: false lets a running deploy finish instead of cutting it in half. The smoke test fails the job if the application does not answer on its health endpoint, which also stops the promotion to the next environment.
NoteThis deploy passes the version on the command line and does not persist it on the server. For automatic rollback on failed health checks and a persisted "last good version", replace the SSH command with a deploy script such as the one described in the CubePath guide on rollback strategies for failed deployments.
Step 6 - Building once and promoting through the environments
Now create the pipeline that builds the image and calls the deploy workflow three times, each call waiting for the previous one.
nano .github/workflows/pipeline.yml
name: pipeline
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- id: meta
run: echo "version=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/your_org/myapp:${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
dev:
needs: build
uses: ./.github/workflows/deploy.yml
with:
environment: dev
version: ${{ needs.build.outputs.version }}
secrets: inherit
staging:
needs: [build, dev]
uses: ./.github/workflows/deploy.yml
with:
environment: staging
version: ${{ needs.build.outputs.version }}
secrets: inherit
production:
needs: [build, staging]
uses: ./.github/workflows/deploy.yml
with:
environment: production
version: ${{ needs.build.outputs.version }}
secrets: inherit
Replace your_org/myapp with your image name. GitHub Container Registry requires lowercase names, so write the owner in lowercase even if your organization name has capitals.
Commit both workflows and push to main:
git add .github/workflows/
git commit -m "Build once and promote through dev, staging and production"
git push origin main
Step 7 - Verifying the promotion and approving production
Open the Actions tab and select the running pipeline workflow. The graph shows build, then dev, then staging. When staging succeeds, the production job shows Waiting and the required reviewers receive a notification.
A reviewer clicks Review deployments, selects production and chooses Approve and deploy. The job then runs with the production secrets.
Check each server to confirm that all three run the same tag. Use docker ps here, because docker compose needs APP_VERSION set and it is not defined in an interactive shell:
docker ps --format '{{.Names}} {{.Image}} {{.Status}}'
myapp-app-1 ghcr.io/your_org/myapp:sha-3f9c2e1 Up 2 minutes
In the repository's Deployments page (on the main page, in the right sidebar under Deployments) you can see which commit is live in each environment and when it was deployed.
Troubleshooting
Host key verification failed. The SSH_KNOWN_HOSTS secret does not match the server, or it was captured for a different hostname than SSH_HOST. Run ssh-keyscan again against exactly the value you stored in SSH_HOST.
denied: denied or unauthorized when pulling on the server. The server is not logged in to ghcr.io, or the token lacks read:packages. Repeat the docker login from Step 2 as the deploy user.
The production job never asks for approval. Required reviewers are not available for private repositories on your plan, or the environment name in the workflow does not match the one in Settings > Environments exactly.
Secret SSH_HOST is required or empty values in the deploy job. Environment secrets are only exposed to jobs that declare that environment. Make sure the secret is created inside the environment, not only at repository level, and that the name matches.
Conclusion
Every push to main now produces one image that is deployed to dev, then staging, and reaches production only after a reviewer approves it, with each environment using its own SSH key and configuration. Next, add your test jobs from the automated testing guide as a dependency of build, add automatic rollback to the deploy step, or move the environments to Kubernetes and manage them with a GitOps tool such as Flux or Argo CD.