--- title: "Deploy to Dev, Staging and Production with GitHub Actions" description: "Build one Docker image per commit and promote it through dev, staging and production using GitHub Actions environments, scoped secrets and manual approval." category: "CI/CD DevOps" language: en source: https://cubepath.com/docs/ci-cd-devops/multi-environment-deployment-with-ci-cd updated: 2026-09-25 --- # Deploy to Dev, Staging and Production with GitHub Actions 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: | 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: ```bash sudo mkdir -p /opt/myapp sudo chown deploy:deploy /opt/myapp ``` Switch to the `deploy` user for the rest of this step: ```bash sudo -iu deploy ``` Create the Compose file. It is identical on all servers; only the image tag and `app.env` change: ```bash nano /opt/myapp/compose.yaml ``` ```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: ```bash nano /opt/myapp/app.env ``` ```ini APP_ENV=dev DATABASE_URL=postgres://myapp:your_dev_db_password@10.0.0.5:5432/myapp_dev LOG_LEVEL=debug ``` Use different values on staging and production, and restrict the file: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```bash ssh-keyscan -t ed25519 your_dev_server_ip ``` ```output 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`: 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: ```bash mkdir -p .github/workflows nano .github/workflows/deploy.yml ``` ```yaml 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. > Note: This 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. ```bash nano .github/workflows/pipeline.yml ``` ```yaml 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`: ```bash 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: ```bash docker ps --format '{{.Names}} {{.Image}} {{.Status}}' ``` ```output 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.