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 deploy user that belongs to the docker group.
  • A GitHub repository with a Dockerfile for 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:

EnvironmentTriggered byGateConfiguration source
devEvery push to mainNonedev environment secrets + server app.env
stagingSuccessful dev deployNonestaging environment secrets + server app.env
productionSuccessful staging deployManual approvalproduction 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:

SecretValue
SSH_HOSTThe server's IP address or hostname
SSH_PRIVATE_KEYThe content of the matching private key file, for example deploy_dev
SSH_KNOWN_HOSTSThe 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:

  1. Enable Required reviewers and add the people or team who can approve production deploys.
  2. Optionally enable Prevent self-review so the person who pushed cannot approve their own deploy.
  3. 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.

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.