DEV Community

khaerul anwar
khaerul anwar

Posted on

Build a Docker CI/CD Pipeline with GitHub Actions and Octopus Deploy

A green build doesn't tell you which image is running on your server. It also doesn't tell you whether a background worker started, or whether your rollback image still exists.

Those are the questions I want a deployment pipeline to answer for me.

I use GitHub Actions and Octopus Deploy across parts of Meldport, EidosStack, and LumenOTP. The applications differ, but the useful habit is the same: record what passed CI, deploy that artifact, then check what actually started.

This tutorial builds a small Node application and takes it through that whole path. You get the files, setup steps, expected results, and failure checks. The example uses a disposable Linux server and a Development environment. It doesn't change any of the three products.

Commit -> tests -> Docker image -> versioned package
       -> Octopus release -> Linux target -> revision check
Enter fullscreen mode Exit fullscreen mode

What this borrows from the three projects

Meldport is an AI API gateway with a web dashboard. Its frontend pipeline builds an image in CI and exports it into a versioned deployment package. Octopus loads that image on the target. The server doesn't rebuild the frontend from source.

The EidosStack backend uses GHCR image digests in its Octopus release packages. That's a different delivery method for the same idea: the deployment should identify the exact image. EidosStack's frontend has its own deployment path, so this is specifically a backend example.

LumenOTP has separate web, worker, and migration images. Keeping their references together helps explain a release when the website is healthy but a worker isn't doing its job.

We'll use Meldport's archive-style delivery for the walkthrough. It lets you follow the artifact without setting up registry pull access on the target. I'll cover the GHCR variation and worker checks near the end.

Before you start

You'll need:

  • A GitHub repository where you can configure Actions secrets and variables.
  • An Octopus instance with permission to create a project, environment, account, and target.
  • A disposable Linux server reachable from your Octopus deployment infrastructure.
  • Docker Engine, the Compose plugin, Bash, curl, jq, gzip, and flock on that server.
  • A development machine with Node 24 and Git. Docker is needed for the local image check.
  • A domain and HTTPS reverse proxy if you want the final public-URL check to pass.

Use a test server rather than a machine already running customer applications. The example binds the app to a loopback port, but it still creates containers and writes deployment files.

The sample app has no database or external API. That keeps the first deployment small enough to understand. Real applications need their own readiness checks, fixtures, and migration process.

1. Create an app whose revision you can check

Make a directory with this layout:

example-web/
  server.mjs
  package.json
  package-lock.json
  Dockerfile
  .dockerignore
  .gitignore
  test/
    server.test.mjs
  deploy/
    compose.yaml
    deploy.sh
  .github/
    workflows/
      release.yml
Enter fullscreen mode Exit fullscreen mode

Add this package file. The lint command deliberately uses Node's syntax checker, so the example doesn't need a linter dependency. An existing application should keep its normal lint and type checks.

{
  "name": "example-web",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24 <25" },
  "scripts": {
    "start": "node server.mjs",
    "lint": "node --check server.mjs && node --check test/server.test.mjs",
    "test": "node --test"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create server.mjs. The health response reports the revision passed at runtime. It doesn't return environment variables or application configuration.

import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';

export function createApp(revision = process.env.APP_REVISION ?? 'local') {
  return createServer((request, response) => {
    if (request.url === '/api/health') {
      response.writeHead(200, { 'content-type': 'application/json' });
      response.end(JSON.stringify({ status: 'ok', revision }));
      return;
    }
    response.writeHead(request.url === '/' ? 200 : 404, {
      'content-type': 'text/plain; charset=utf-8'
    });
    response.end(request.url === '/' ? 'Example web is running.\n' : 'Not found\n');
  });
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  createApp().listen(Number(process.env.PORT ?? 3000), '0.0.0.0');
}
Enter fullscreen mode Exit fullscreen mode

The server binds to 0.0.0.0 inside its container. Binding only to container-local 127.0.0.1 would make the published Docker port unable to reach it.

For a real service, separate a basic liveness endpoint from readiness that checks required dependencies. A web process can answer HTTP while its database connection is broken.

Add test/server.test.mjs:

import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import { createApp } from '../server.mjs';

test('web routes and the expected revision', async (t) => {
  const app = createApp('test-revision');
  app.listen(0, '127.0.0.1');
  await once(app, 'listening');
  t.after(() => new Promise((resolve) => app.close(resolve)));
  const origin = `http://127.0.0.1:${app.address().port}`;

  const health = await fetch(`${origin}/api/health`);
  assert.equal(health.status, 200);
  assert.deepEqual(await health.json(), {
    status: 'ok',
    revision: 'test-revision'
  });
  assert.equal((await fetch(origin)).status, 200);
  assert.equal((await fetch(`${origin}/missing`)).status, 404);
});
Enter fullscreen mode Exit fullscreen mode

Generate the lockfile and run the checks:

npm install --package-lock-only --ignore-scripts
npm ci
npm run lint
npm test
Enter fullscreen mode Exit fullscreen mode

You should get one passing test that exercises health, the root route, and a missing route. Commit package-lock.json with the application. CI will use npm ci, which expects the package file and lockfile to agree.

To try the app manually in a Unix shell:

APP_REVISION=local-check npm start
Enter fullscreen mode Exit fullscreen mode

In another terminal:

curl -fsS http://127.0.0.1:3000/api/health
Enter fullscreen mode Exit fullscreen mode

The expected response is {"status":"ok","revision":"local-check"}. Stop the local process before moving on.

2. Build an image before involving Octopus

Create Dockerfile:

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000
COPY --chown=node:node server.mjs ./
USER node
EXPOSE 3000
CMD ["node", "server.mjs"]
Enter fullscreen mode Exit fullscreen mode

This app has no runtime dependencies, so it only copies the server file. A real Next.js or API service will have a different Dockerfile, often with a separate build stage.

The container runs as the image's non-root node user. The base-image tag is readable but mutable; pin a reviewed base-image digest when making a controlled production build.

Create .dockerignore:

node_modules
.git
.env*
*.log
Enter fullscreen mode Exit fullscreen mode

Also add .gitignore:

node_modules/
.env*
runtime.env
health.json
package/
*.zip
Enter fullscreen mode Exit fullscreen mode

Check the image locally:

docker build --tag example-web:local .
docker run --rm --name example-web-local \
  -e APP_REVISION=local-image \
  -p 127.0.0.1:3180:3000 example-web:local
Enter fullscreen mode Exit fullscreen mode

In another terminal, request http://127.0.0.1:3180/api/health. Its revision should be local-image. Stop the foreground container with Ctrl+C when you're finished.

This step catches container problems before you spend time configuring deployment permissions. A test passing on your laptop doesn't prove the image contains the files needed at runtime.

3. Prepare the Linux target once

Install Docker Engine and the Compose plugin using the official Docker instructions for your distribution. Check the remaining tools with:

docker version
docker compose version
bash --version
curl --version
jq --version
command -v flock
command -v gzip
Enter fullscreen mode Exit fullscreen mode

Create a dedicated Linux deployment account through your normal server-administration process. The example below calls it deploy; that is a placeholder, not an account from our infrastructure.

As a server administrator:

sudo install -d -m 750 -o deploy -g deploy /opt/example-web
sudo install -m 600 -o deploy -g deploy /dev/null /opt/example-web/runtime.env
Enter fullscreen mode Exit fullscreen mode

Run the second command only for a new tutorial directory. It creates an empty file for this dependency-free demo and would replace an existing file.

The deploy account must be able to use Docker. Docker-daemon access is powerful, often equivalent to root access on the host, so limit who can use that account and where its SSH key is accepted. Don't solve permissions with chmod 777.

For an application with secrets, populate runtime.env securely on the target or inject sensitive values through Octopus. Keep that file out of Git, Docker build contexts, and deployment packages.

For the final URL check, configure your existing HTTPS reverse proxy to send the tutorial hostname to 127.0.0.1:3180. DNS, certificates, and proxy installation are outside this walkthrough. If that route isn't ready, the final pipeline check will fail even when local container readiness passes.

4. Add the target and project in Octopus

Create a Development environment for this exercise.

Then add a Linux SSH target under Infrastructure > Deployment Targets. Select the SSH connection type, enter your tutorial server address, and verify its host-key fingerprint through a trusted server-administration channel before accepting it. Choose the deployment account you prepared.

Assign the target to Development and give it a target tag such as example-web. Run its health check. If Octopus cannot connect, fix that connection before creating a release.

Octopus documents the SSH target setup and requirements. An SSH target does not require the Tentacle agent, but it still needs the tooling required by Octopus's target implementation.

Create a project named ExampleWeb. Add a Run a Script step:

Setting Value for this tutorial
Execution location Each deployment target
Target tag example-web
Script source File inside a package
Package feed Octopus built-in package repository
Package ID Example.Web.Deploy
Script deploy.sh
Script language Bash

An Octopus script step with a referenced package selected for acquisition and extraction.

Package-reference UI example from the Octopus script-step documentation. This is a vendor documentation screenshot, not our private deployment process. Labels may differ by version.

Make sure the package is acquired and extracted before the script runs. Script-source names vary slightly by Octopus version; the intended result is a target-side Bash script loaded from the selected ZIP. See the script-step documentation for those options.

Use a lifecycle that allows the Development environment, with no automatic deployment on release creation. Our workflow explicitly queues the deployment. Enabling both paths can result in an unintended duplicate deployment.

At this point you have a target and a process, but nothing to deploy yet.

5. Decide what belongs in the package

The ZIP will contain four files:

File Why it travels
image.tar.gz The image built and smoke-tested by CI
revision.txt The source commit expected inside that image
compose.yaml The service definition for this release
deploy.sh The target-side deployment and recovery script

The filename follows Example.Web.Deploy.<version>.zip. The package ID must match the ID selected in your Octopus step.

We use a release number based on the GitHub run number and attempt. A rerun gets a different package version. Don't overwrite an earlier package and make an old release refer to new contents.

6. Add Compose and a deployment script

Put this in deploy/compose.yaml:

services:
  web:
    image: ${APP_IMAGE:?APP_IMAGE is required}
    restart: unless-stopped
    env_file:
      - runtime.env
    environment:
      APP_REVISION: ${APP_REVISION:?APP_REVISION is required}
    ports:
      - "127.0.0.1:3180:3000"
Enter fullscreen mode Exit fullscreen mode

The port is published only on the host's loopback interface. Your HTTPS reverse proxy can reach it; it isn't a direct public Docker port.

The script below imports the packaged image and checks its revision label. It then retains the current release files, starts the candidate, and probes the running revision. If the candidate fails, it restores the previous files and probes that revision too.

Keep shell scripts in LF format, especially if you edit on Windows. A repository rule such as *.sh text eol=lf in .gitattributes helps. The script changes into its own package directory so it doesn't depend on Octopus choosing a particular initial working directory.

Save it as deploy/deploy.sh:

#!/usr/bin/env bash
set -euo pipefail

cd -- "$(dirname -- "${BASH_SOURCE[0]}")"

root=/opt/example-web
mkdir -p "$root"
exec 9>"$root/deploy.lock"
flock -n 9 || { echo "Another deployment is running"; exit 1; }

revision=$(cat revision.txt)
[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || exit 1
image="app:$revision"

gzip -dc image.tar.gz | docker image load
label=$(docker image inspect "$image" \
  --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')
[[ "$label" == "$revision" ]] || exit 1

if [[ -f "$root/release.env" ]]; then
  cp "$root/release.env" "$root/previous-release.env"
  cp "$root/compose.yaml" "$root/previous-compose.yaml"
fi

install -m 640 compose.yaml "$root/compose.yaml"
printf 'APP_IMAGE=%s\nAPP_REVISION=%s\n' "$image" "$revision" \
  > "$root/release.env"

compose() {
  docker compose --project-name example-web \
    --project-directory "$root" \
    --env-file "$root/release.env" \
    -f "$root/compose.yaml" "$@"
}

if compose up -d; then
  for attempt in $(seq 1 30); do
    if curl --connect-timeout 2 --max-time 5 -fsS http://127.0.0.1:3180/api/health |
      jq -e --arg expected "$revision" '.revision == $expected' >/dev/null; then
      echo "Ready: $revision"
      exit 0
    fi
    sleep 2
  done
fi

echo "Deployment failed its readiness check" >&2
if [[ -f "$root/previous-release.env" ]]; then
  cp "$root/previous-release.env" "$root/release.env"
  cp "$root/previous-compose.yaml" "$root/compose.yaml"
  compose up -d
  previous=$(sed -n 's/^APP_REVISION=//p' "$root/release.env")
  restored=false
  for attempt in $(seq 1 30); do
    if curl --connect-timeout 2 --max-time 5 -fsS http://127.0.0.1:3180/api/health |
      jq -e --arg expected "$previous" '.revision == $expected' >/dev/null; then
      restored=true
      break
    fi
    sleep 2
  done
  [[ "$restored" == true ]] || echo "Rollback also needs attention" >&2
fi
exit 1
Enter fullscreen mode Exit fullscreen mode

A few details are easy to miss here:

  • The deployment lock is per application directory. Two releases must not rewrite the same Compose state at once.
  • Compose uses a fixed project name so successive releases update the same service.
  • The script copies both the old image reference and Compose file. An image reference alone isn't the full previous service definition.
  • A failed candidate still exits with a failure code after recovery. Octopus should report that release as failed, even if the previous app is serving traffic again.
  • The label check catches an artifact mix-up. It is not a signature or proof that an untrusted package is safe.

This script doesn't version runtime.env. An operator changing that file must consider compatibility with the previous app. On a first deployment there is no previous release to restore.

7. Connect GitHub to the Octopus project

Create these repository variables under Settings > Secrets and variables > Actions:

Name What to enter
OCTOPUS_URL Your Octopus instance URL
OCTOPUS_SPACE The space containing ExampleWeb
OCTOPUS_PROJECT ExampleWeb
APP_PUBLIC_URL Your tutorial app's HTTPS origin, without a trailing slash

Create OCTOPUS_API_KEY as a repository secret. Use a deployment service account with the permissions needed to upload this package and create/deploy this project's releases. Keep its project, environment, and feed access as narrow as your Octopus setup permits. A key alone doesn't define its scope; the service account's roles and teams do.

Octopus also provides an OIDC login action. This tutorial uses the API-key route to keep the setup visible in one workflow, not because long-lived credentials are required.

Create .github/workflows/release.yml:

name: Release web

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: example-web-development
  cancel-in-progress: false

jobs:
  release:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    env:
      OCTOPUS_URL: ${{ vars.OCTOPUS_URL }}
      OCTOPUS_SPACE: ${{ vars.OCTOPUS_SPACE }}
      OCTOPUS_API_KEY: ${{ secrets.OCTOPUS_API_KEY }}

    steps:
      - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09

      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
        with:
          node-version: 24
          cache: npm

      - name: Verify
        run: |
          npm ci
          npm run lint
          npm test

      - name: Build
        run: |
          docker build \
            --label "org.opencontainers.image.revision=$GITHUB_SHA" \
            --tag "app:$GITHUB_SHA" .

      - name: Smoke test the image
        shell: bash
        run: |
          set -euo pipefail
          trap 'docker rm -f smoke >/dev/null 2>&1 || true' EXIT
          docker run -d --name smoke \
            -e APP_REVISION="$GITHUB_SHA" \
            -p 127.0.0.1:3180:3000 "app:$GITHUB_SHA"
          for attempt in $(seq 1 30); do
            if curl --connect-timeout 2 --max-time 5 -fsS http://127.0.0.1:3180/api/health > health.json; then
              break
            fi
            sleep 2
          done
          jq -e --arg expected "$GITHUB_SHA" \
            '.revision == $expected' health.json

      - name: Package
        id: package
        shell: bash
        run: |
          set -euo pipefail
          version="1.0.${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}"
          mkdir -p package
          cp deploy/compose.yaml deploy/deploy.sh package/
          printf '%s\n' "$GITHUB_SHA" > package/revision.txt
          docker save "app:$GITHUB_SHA" | gzip -1 > package/image.tar.gz
          (cd package && zip -q "../Example.Web.Deploy.$version.zip" *)
          echo "version=$version" >> "$GITHUB_OUTPUT"

      - name: Upload package
        uses: OctopusDeploy/push-package-action@3c77d39c57b633b369ed19b560fecd2e10dc0403
        with:
          api_key: ${{ secrets.OCTOPUS_API_KEY }}
          server: ${{ vars.OCTOPUS_URL }}
          space: ${{ vars.OCTOPUS_SPACE }}
          packages: Example.Web.Deploy.${{ steps.package.outputs.version }}.zip

      - name: Create release
        uses: OctopusDeploy/create-release-action@62df9f2a0e9198f4b4e4b7fd333e212b162215cd
        with:
          project: ${{ vars.OCTOPUS_PROJECT }}
          release_number: ${{ steps.package.outputs.version }}
          package_version: ${{ steps.package.outputs.version }}
          release_notes: "Commit ${{ github.sha }}, run ${{ github.run_id }}"

      - name: Deploy
        id: deploy
        uses: OctopusDeploy/deploy-release-action@74c748ccbf1962c1e66c4a8c1206fbf4c5ab07a5
        with:
          project: ${{ vars.OCTOPUS_PROJECT }}
          release_number: ${{ steps.package.outputs.version }}
          environments: Development

      - name: Wait for deployment
        uses: OctopusDeploy/await-task-action@cd2af689649c4bec7ee87f3ee22291a3376a187b
        with:
          server_task_id: ${{ fromJson(steps.deploy.outputs.server_tasks)[0].serverTaskId }}
          timeout_after: "900"

      - name: Verify public revision
        env:
          APP_PUBLIC_URL: ${{ vars.APP_PUBLIC_URL }}
        run: |
          curl --connect-timeout 2 --max-time 5 -fsS "$APP_PUBLIC_URL/api/health" |
            jq -e --arg expected "$GITHUB_SHA" '.revision == $expected'
Enter fullscreen mode Exit fullscreen mode

The action references are full commit SHAs. They're public dependency versions, not secrets. Review and update them as dependencies change.

The workflow only deploys from main. A manual run on another branch won't pass the job's branch condition. If your repository uses a different default branch, change both the event filter and condition.

8. Read the workflow as a series of checks

Verify installs the committed dependency tree and runs the project's checks. In a real app, add integration tests against disposable databases. Don't give a pull-request test your production database URL.

Build produces an image tagged with the source commit. That name is useful for tracking the build. The label records the expected revision too.

Smoke test starts that image on the runner, requests its health endpoint, and compares the returned revision. The cleanup trap removes the test container even when the check fails. Our demo requires no external service; a real app may need temporary dependencies or a dedicated smoke-test configuration.

Package exports the same built image. It doesn't build another one for deployment. The ZIP also captures the Compose file and script used by that release.

Upload, release, and deploy are separate operations. Uploading a package does not start a container. Creating a release records the selected package version and deployment process. Deploying queues its execution for Development.

Wait for deployment matters because the deploy action returns task IDs. A queued task can still fail while downloading or running its script. This workflow targets one environment and waits for its single returned task. If you add several environments, wait for all returned tasks.

Verify public revision checks the HTTPS route after Octopus finishes. It can catch an old reverse-proxy route, stale instance, or wrong hostname. A failure here does not automatically invoke the target-side rollback script. Investigate the route before deciding whether to redeploy the previous release.

The Octopus GitHub Actions reference describes the release actions and their inputs.

9. Run the first deployment and inspect the result

Commit the example files and push them through your repository's normal review process to main. Alternatively, trigger the workflow manually from that branch.

In GitHub Actions, check the stages in order. If Verify fails, no package should be deployed. If the image smoke test fails, fix the image rather than bypassing that check.

In Octopus, open the new release. Confirm that its package version matches the version printed by the workflow, its environment is Development, and its target is the tutorial server.

After a successful deployment, the target-side check should return the same commit as the workflow:

curl -fsS http://127.0.0.1:3180/api/health | jq .
Enter fullscreen mode Exit fullscreen mode

The expected shape is:

{
  "status": "ok",
  "revision": "<the-full-commit-from-your-workflow>"
}
Enter fullscreen mode Exit fullscreen mode

From your own machine, request the tutorial hostname as well:

curl -fsS https://app.example.com/api/health | jq .
Enter fullscreen mode Exit fullscreen mode

Replace app.example.com with your domain. It is an illustrative hostname, not a service you should expect to exist.

For local diagnosis on the target, use the same Compose arguments as the deployment script:

docker compose --project-name example-web \
  --project-directory /opt/example-web \
  --env-file /opt/example-web/release.env \
  -f /opt/example-web/compose.yaml ps

docker compose --project-name example-web \
  --project-directory /opt/example-web \
  --env-file /opt/example-web/release.env \
  -f /opt/example-web/compose.yaml logs --tail=80 web
Enter fullscreen mode Exit fullscreen mode

Keep logs out of public posts if they contain customer data or credentials. For a deployment problem, the release number, expected revision, and redacted error are usually better starting points than dumping an environment file.

10. Understand recovery before testing a failure

On a disposable environment, first establish a healthy release A. For a recovery exercise, create a candidate whose health response intentionally returns a wrong revision. The CI smoke test should reject that candidate before it reaches Octopus.

That is a useful first result: the earlier check prevented the broken release.

To exercise target-side recovery, use a separate test harness with a candidate that passes the CI contract but fails its target-specific readiness check. Don't weaken the production workflow to force a failure through. A target dependency or fixture-controlled health response can model that case in isolation.

After recovery, check that the previous revision answers again and that Octopus marks the candidate deployment failed. Don't accept a log line saying “restored” as the only proof.

This single-instance Compose deployment can interrupt requests during replacement and recovery. For uninterrupted traffic, run a second instance, verify it, and switch the proxy. That needs a different deployment script; this one doesn't pretend to provide it.

11. Adjust the pattern for APIs, workers, and registries

For the EidosStack backend-style approach, push the tested image to GHCR and capture the digest from the build/push operation. Put that digest in the release manifest, then pull it on the target. A commit-based tag is a label people can read; a digest identifies image content.

The server needs its own authorized registry access. CI successfully pushing a package doesn't prove the target can pull it. Test package permissions and the exact digest before changing deployment logic.

For LumenOTP-style services, include the web, worker, and migration image references together. Check worker readiness or a recent heartbeat separately from web health. An idle worker may be healthy; a process existing is not proof it can reach its queue. Pick a signal that matches the worker's actual job.

For database-backed applications, take a verified backup before schema changes and favor backward-compatible migrations. Restoring yesterday's image doesn't undo today's schema. A destructive migration needs its own recovery decision.

For frontend applications, watch build-time public configuration. If a framework bakes API origins into the browser bundle, moving the same image between environments won't replace those values. Either make those settings runtime-configurable or build explicitly environment-specific artifacts and label them honestly.

12. Troubleshoot the stage that actually failed

Symptom Check first
Octopus reports missing package Package ID, uploaded version, selected feed, release package selection
SSH target health fails Network reachability, host-key verification, SSH account, Octopus target prerequisites
Compose can't read runtime.env File location, owner, permissions, deployment account
Container starts but health is unreachable App binds to 0.0.0.0 inside container; correct ports; startup logs
Health returns the wrong revision Image reference, runtime variable, target selection, stale proxy route
GitHub finishes while deployment is still running Whether the workflow waits for the returned Octopus task
Registry pull fails Exact digest exists; target credential can read that package
Local health passes but public check fails DNS, TLS, reverse proxy, cache behavior, wrong host route
Worker is stale but website works Worker image reference, queue connection, heartbeat, worker logs

When you clean disk space, retain the running artifact and a known previous release. Avoid broad cleanup commands that remove volumes or every image on a shared host. A build cache is replaceable; an untested backup deletion isn't part of normal deployment housekeeping.

Moving from the tutorial to production

Add Production as a separate lifecycle phase once Development is reliable. Promote the already-reviewed release when its environment configuration allows that. Octopus lifecycles can control promotion order and retention.

An example Octopus lifecycle adds a Development phase and environment.

Lifecycle UI example from the Octopus lifecycle documentation. This is a documentation example, not one of our project accounts.

Put approval at a deliberate point. That might be an Octopus intervention step or a protected deployment job, depending on your team. Don't copy a workflow that automatically deploys to Production and assume the environment name itself adds approval.

Keep the CI runner away from your production application host where possible. A runner executes repository code, including build scripts. Restrict self-hosted runners to trusted jobs and keep untrusted pull requests on appropriately isolated infrastructure. GitHub explains the maintenance responsibilities in its self-hosted runner documentation.

The test I want at the end is straightforward: I can identify the artifact, see what is running, and explain how recovery works. That is useful for a small dashboard, an API backend, or a service with workers. Extra deployment machinery can wait until there's a specific failure it needs to prevent.


Disclosure: AI tools helped draft this tutorial from project workflows and documentation. The UI screenshots are from the linked Octopus documentation. The demo's local checks are reported separately from a real Octopus deployment; the tutorial has not been deployed to our production services.

Top comments (0)