ScreenshotNeo

BlogHow-to

How to Set Up a Docker Staging Environment for Web Testing

Build a repeatable Docker Compose staging stack, check service readiness, run web tests in isolation, and clean up safely.

By the ScreenshotNeo team4 October 202611 min read

Use Docker Compose to define your web application and its dependencies, then start an isolated stack, wait for its services to become healthy, run your web tests, and remove the stack. Keep shared settings in a base compose.yaml; express staging-specific changes with a Compose profile or an override file. Give each concurrent run a unique project name.

Docker describes Compose as usable in production, staging, development, testing, and CI workflows in its Docker Compose overview. You do not necessarily need a complete Compose file for every environment; Docker explains the alternatives in its profiles guidance and multiple-file merge guidance.

1. Decide what “staging” means for your tests

A staging environment can be a temporary local or CI stack used for automated tests, or a shared deployment that teammates and testers access through a URL. The first is usually created for one run and destroyed afterward. The second needs an appropriately secured remote Docker host and operational ownership. The Compose configuration can be similar, while its exposure, credentials, data, and lifecycle differ.

Start with a representative application stack: the same application image and key dependencies your tests need. Keep environment-specific differences explicit. Avoid connecting staging to production services or data.

2. Add a base Compose configuration

The example below uses a Node.js web service and PostgreSQL. Adapt the Dockerfile command, health check, database version, and test command to your application. The web app should connect to the database by the Compose service name db, not by a container IP address. Compose provides service-name discovery on its network.

Project layout

myapp/
├── Dockerfile
├── compose.yaml
├── compose.staging.yaml
├── package.json
├── package-lock.json
└── src/

Dockerfile

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

Use the runtime version and install steps your application requires. For a staging image intended to resemble a deployed build, build the application in the image and avoid relying on a source-code bind mount.

compose.yaml

services:
  web:
    build: .
    init: true
    environment:
      PORT: "3000"
      DATABASE_HOST: db
      DATABASE_PORT: "5432"
      DATABASE_NAME: app
      DATABASE_USER: app
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 5s

secrets:
  db_password:
    file: ./secrets/db_password.txt

volumes:
  db_data:

This example assumes the web service reads a password from DATABASE_PASSWORD_FILE and exposes a successful /health endpoint. Add that behavior if your app does not already support it. PostgreSQL’s official image supports POSTGRES_PASSWORD_FILE. Create the secret file locally and keep it out of version control:

mkdir -p secrets
printf '%s' 'replace-with-a-local-staging-password' > secrets/db_password.txt

Add secrets/ to .gitignore. Docker’s Compose secrets guidance recommends secrets for sensitive values such as passwords instead of ordinary environment variables. For production or a shared staging host, use a suitable managed secret source and access controls for your organization.

The sample health check is application-specific: it assumes Node.js has global fetch and that /health reports readiness. If the route is different, update the check. The database check confirms PostgreSQL accepts connections; it does not prove that migrations or application-specific seed data are ready.

3. Choose how to express staging differences

Option A: Override file

A base file plus an override works well when staging changes settings on shared services. Compose merges files in order: later files override or add configuration. Relative paths in merged files are resolved from the first Compose file, so an override in a subdirectory does not change the base for relative paths.

# compose.staging.yaml
services:
  web:
    restart: unless-stopped
    environment:
      APP_ENV: staging
      LOG_LEVEL: info
    ports:
      - "127.0.0.1:8080:3000"

  db:
    restart: unless-stopped

For local testing, binding to 127.0.0.1 keeps the published port on the local machine. For a remote shared staging URL, configure network exposure and access control deliberately for that host; this example does not establish a secure public deployment by itself.

Inspect the fully merged result before starting it:

docker compose -f compose.yaml -f compose.staging.yaml config

Option B: Profile

Profiles are useful when staging needs optional services or a group of services that should only start in that mode. A profile can also be used to select staging-only tools. Profiles do not automatically rewrite a service’s settings; for setting overrides, a second Compose file is often clearer.

services:
  adminer:
    image: adminer
    profiles: ["staging-tools"]
    ports:
      - "127.0.0.1:8081:8080"
docker compose --profile staging-tools up -d

Do not expose optional administration tools on a shared host without deciding who can reach them and how access is controlled.

Approach Use it when Review point
Profiles You need to toggle groups of optional services. Confirm which profile is active and which services it selects.
Base plus override Staging changes settings on services that are already in the base stack. Run docker compose ... config to inspect the merged result.

You do not need to maintain entirely separate development, testing, and staging Compose files for every project. Choose the smallest clear set of shared configuration and environment-specific differences.

4. Start, inspect, and check the stack

Use a unique project name for each simultaneous branch environment or CI run. Compose uses it to namespace resources, helping separate containers, networks, and volumes for concurrent copies of the same project.

PROJECT="myapp-staging-${USER:-developer}"
docker compose -p "$PROJECT" -f compose.yaml -f compose.staging.yaml config
docker compose -p "$PROJECT" -f compose.yaml -f compose.staging.yaml up -d --build
docker compose -p "$PROJECT" -f compose.yaml -f compose.staging.yaml ps

Check application and dependency logs if a service is unhealthy:

docker compose -p "$PROJECT" -f compose.yaml -f compose.staging.yaml logs -f web db

Run a command inside a running service with exec, for example to inspect application configuration:

docker compose -p "$PROJECT" -f compose.yaml -f compose.staging.yaml exec web node -e "console.log(process.env.DATABASE_HOST)"

Once the web health check passes, the example app is available locally at http://localhost:8080. The Compose file uses a host port mapping; choose a different unused host port if needed. A service marked running is not necessarily ready to serve requests.

5. Run web tests against the environment

Run your test runner from the host or in a dedicated test-runner service. The test must target the address it can actually reach: a host-run test can use http://localhost:8080; a test container on the Compose network should use http://web:3000.

Example host-run test command

# Start the stack first, then run the project's browser or API test suite.
BASE_URL=http://localhost:8080 npm run test:e2e

Replace npm run test:e2e with the command defined by your project. A browser-based suite may need its own browser dependencies or a test-runner image; keep that runner in the same Compose project if it needs service-name networking.

One-off test-runner service

For containerized tests, add a test service that shares the application network. Adapt the image and command to your test framework:

services:
  e2e:
    image: node:22-alpine
    working_dir: /tests
    volumes:
      - ./:/tests:ro
    environment:
      BASE_URL: http://web:3000
    depends_on:
      web:
        condition: service_healthy
    command: ["sh", "-lc", "npm ci && npm run test:e2e"]

This simple runner installs dependencies on each invocation. For faster CI, build a dedicated test image with locked dependencies. Browser-based frameworks may require a browser-enabled image and additional system libraries. If the runner exits with a failure, preserve its exit status in the CI job and collect its logs before teardown.

6. Tear down and isolate repeated runs

Remove the stack after a test run. Plain down removes containers and networks but leaves named volumes, which can preserve database state. Use -v when each run should start with a clean database:

docker compose -p "$PROJECT" -f compose.yaml -f compose.staging.yaml down -v --remove-orphans

Only use -v when deleting that project’s data is intended. Avoid reusing a project name for parallel jobs; a unique name per branch or CI run prevents resource collisions. Compose documents isolated test environments as a use case: create the environment, run tests, then destroy it.

CI lifecycle pattern

set -eu
PROJECT="myapp-ci-${CI_JOB_ID:-local}"
FILES="-f compose.yaml -f compose.staging.yaml"

cleanup() {
  docker compose -p "$PROJECT" $FILES down -v --remove-orphans
}
trap cleanup EXIT

docker compose -p "$PROJECT" $FILES up -d --build
# Add an explicit readiness wait if your CI runner starts tests before
# Compose health checks have completed.
docker compose -p "$PROJECT" $FILES ps
BASE_URL=http://localhost:8080 npm run test:e2e

In a shell script, arrays are safer than a string for building optional arguments when values might contain spaces. This compact example assumes the Compose file paths contain no spaces. CI platforms also have their own cleanup guarantees; use the platform’s job lifecycle and logs as appropriate.

7. Local stacks versus a shared remote staging host

Consideration Local or CI stack Remote shared stack
Access Best for the developer or job that started it. Can be reached by teammates or testers when appropriately configured.
Lifecycle Usually created for a test run and removed afterward. Needs an owner for updates, cleanup, and availability.
Network and credentials Keep ports local where possible and isolate test credentials and data. Protect remote access and credentials according to your organization’s requirements.

Docker supports remote hosts using Docker client environment settings such as DOCKER_HOST, DOCKER_TLS_VERIFY, and DOCKER_CERT_PATH. Configure access according to Docker’s remote access guidance. The right host, network layout, and access policy depend on the application and organization.

8. Capture a page from the staging environment

Automated assertions are usually the best way to verify behavior. A screenshot can also help review a rendered page or attach a visual artifact to a test workflow. Capture a URL that the screenshot service can reach; a local-only address such as localhost is not reachable from an external service. For a shared staging deployment, use its accessible staging URL and protect the page with suitable access controls.

For local browser automation, point your test runner at the mapped host port and save a screenshot as a test artifact. For an externally reachable staging URL, ScreenshotNeo is a website screenshot API and MCP server. Its API can capture a page as PNG, JPEG, WebP, or PDF.

Or skip the browser setup

For an externally reachable staging page, one GET request can capture it. See the ScreenshotNeo API documentation for the request options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://staging.example.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://staging.example.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://staging.example.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 screenshots. Every feature is on every plan.

Sign up free for 1,000 screenshots a month with no card.

9. Troubleshooting

Symptom Likely cause Fix
Web service starts before the database accepts connections. depends_on without a health condition only controls start order. Add a database health check and use condition: service_healthy. Make the application retry transient connection failures as well.
Database health check never becomes healthy. Credentials or database name differ, initialization failed, or the check does not match the image. Inspect docker compose logs db, verify the secret and database settings, and confirm the health check command is available in the image.
The stack reports a port is already allocated. Another process or Compose project uses the host port. Choose another host port in the override, or stop the conflicting service. Keep project names unique for parallel runs.
Tests cannot connect to the web service. The test uses the wrong address for its network. Use localhost plus the published host port for host-run tests; use the Compose service name and container port from a test container.
Compose cannot find a secret file. The file is absent or its relative path is incorrect. Create the local file at the path in the base Compose file. Remember paths in merged files resolve relative to the first Compose file.
Staging configuration differs from what you expect. An override did not load, a later file changed a value, or a profile is inactive. Run docker compose -f compose.yaml -f compose.staging.yaml config and inspect the resolved configuration.
Data from an earlier test appears in a new run. The named volume was retained by down. Use a fresh project name or remove the project’s volumes with down -v when data deletion is intended.
A remote tester cannot access the app. The service is bound only to loopback, the host is unreachable, or access controls block the request. Review host port binding, network reachability, and the remote host’s access policy. Do not expose a service publicly without appropriate protection.
A health check passes but tests still fail. The health endpoint confirms basic readiness but not migrations, seed data, or a required downstream service. Make readiness reflect the conditions tests require, and run migrations or setup steps before the test command.

10. Performance, reliability, and cost

  • Build time: Keep dependency installation in a cached Docker build layer by copying lockfiles before application source. Use a dedicated test image if repeated dependency installation dominates CI time.
  • Startup reliability: Health checks and retry-aware application connections handle the gap between a container starting and its service being ready. Choose intervals and retry limits that fit actual startup behavior.
  • Clean state: A disposable project and volume per test run make results more repeatable, at the cost of reinitializing the database each time.
  • Parallel jobs: Unique project names and non-conflicting published ports prevent concurrent environments from interfering. Containers that only communicate on the Compose network may not need published ports.
  • Resource use: Each stack consumes resources for its application, database, browser runner, and any optional services. Limit concurrent jobs to what the host can handle.
  • Cost: Local and CI Docker stacks use the machine or runner allocated to them. A remote host can make staging accessible beyond one machine, but its infrastructure cost depends on the host and workload; the Docker guidance here does not provide a universal price or performance figure.

FAQ

Should staging use production data?

Keep staging data isolated from production. Use representative test data that your organization permits, and keep credentials separate.

Do I need to publish every container port?

No. Publish only ports that need to be reached from outside the Compose network, such as the web app for host-run tests. Services can communicate over the Compose network by service name.

Can I use the same Compose project name on every CI run?

Use a unique name for simultaneous runs. Reusing a name can cause one run to operate on another run’s resources.

Does a healthy container guarantee that the whole application is ready?

No. A health check only verifies its configured condition. Include the prerequisites your tests depend on, such as migrations or required seed data.