Back to Blog

From 'Works on My Machine' to Production: Docker + convox.yml Done Right

It was 2 AM on a Tuesday when the Slack alerts started firing. The payment processing service that had passed every test in staging was now throwing cryptic errors in production. After three hours of debugging, the team discovered the culprit: staging was running Node.js 18.17 while production had quietly drifted to 18.19 during a routine infrastructure update. A minor version difference had exposed a subtle change in how the crypto module handled certain edge cases.

This is the "works on my machine" problem scaled up to infrastructure. Environment drift between development, staging, and production has killed more release schedules and caused more weekend incidents than most teams care to admit. The promise of containers was supposed to solve this, but Docker alone is not enough. You need a systematic approach that ensures the same configuration deploys identically across every environment.

That systematic approach is what Convox provides through the combination of Docker and convox.yml. In this guide, we will walk through building a deployment workflow that eliminates environment drift from your localhost all the way to production.

The Hidden Cost of Environment Drift

Environment drift does not announce itself. It accumulates quietly through dozens of small decisions and forgotten updates. Consider a typical web application and the many places where drift can occur:

Runtime versions: Your local machine runs Node 20.10, the staging server has Node 20.8 from three months ago, and production is on Node 20.12 because someone applied security patches. Each version has subtle behavioral differences.

Database versions: Development uses PostgreSQL 15 from Homebrew, staging runs PostgreSQL 14.9 in a managed service, and production is on PostgreSQL 14.6 because upgrading requires a maintenance window nobody has scheduled.

Environment variables: A developer adds FEATURE_FLAG_NEW_CHECKOUT=true to their local .env file to test a feature. It works perfectly. They forget to add it to staging. The feature ships to production without the flag, causing a partial rollout that confuses users.

System dependencies: The application uses ImageMagick for image processing. Local machines have version 7.1, staging has 6.9, and nobody remembers what version production runs. A new image format works locally but fails silently in production.

Teams coming from Heroku often experience this shock when they move to self-managed infrastructure. Heroku's buildpacks provided implicit consistency by making the same decisions everywhere. When you leave that ecosystem, you inherit the responsibility of maintaining parity yourself. Docker Compose helps locally, but it does not extend to staging and production without significant additional tooling.

The Solution: One Configuration, Every Environment

The key insight behind Convox's approach is that your application's runtime requirements should be defined once and applied everywhere. The convox.yml file serves as the single source of truth for how your application runs, regardless of whether it is deploying to a local development Rack, a staging environment, or production infrastructure.

Combined with a well-structured Dockerfile, this creates what Docker deployment workflows promise but rarely deliver: true environment parity from development through production.

Let us build this step by step using a Node.js web application with a PostgreSQL database as our running example.

Building a Consistent Dockerfile

The Dockerfile is where environment consistency begins. Every decision you make here propagates to every environment where your application runs. The goal is to be explicit about everything and leave nothing to chance.

Here is a production-ready Dockerfile for a Node.js application:

# Use explicit version tags, never 'latest' or 'lts'
FROM node:20.10.0-alpine AS builder

WORKDIR /app

# Copy dependency files first for better layer caching
COPY package.json package-lock.json ./

# Use ci for reproducible installs
RUN npm ci --only=production

# Copy application code
COPY . .

# Build step if needed (TypeScript, bundling, etc.)
RUN npm run build --if-present

# Production stage - minimal image
FROM node:20.10.0-alpine

WORKDIR /app

# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# Copy only what we need from builder
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./

USER nodejs

EXPOSE 3000

CMD ["node", "dist/server.js"]

Several decisions in this Dockerfile directly prevent environment drift:

Explicit version pinning: We use node:20.10.0-alpine instead of node:20 or node:lts. This ensures the exact same Node.js version runs in every environment. When you want to upgrade, you change this version deliberately and test the change, rather than discovering it broke something in production.

Multi-stage builds: The builder stage installs dependencies and compiles code. The production stage copies only the artifacts needed to run. This reduces image size and attack surface while ensuring build tools are not available in production.

Deterministic dependency installation: Using npm ci instead of npm install ensures the exact versions from package-lock.json are installed. This prevents the subtle bugs that occur when a dependency releases a patch version between your staging and production deploys.

For more Dockerfile patterns and optimization techniques, see the Convox Dockerfile documentation.

Defining Your Application with convox.yml

The convox.yml file defines everything about how your application runs: services, resources, environment variables, health checks, and scaling parameters. This single file deploys identically to any Convox Rack or Cloud Machine.

Here is a complete convox.yml for our Node.js application:

environment:
  - NODE_ENV=production
  - LOG_LEVEL=info

resources:
  database:
    type: postgres
    options:
      version: "15"

services:
  web:
    build: .
    port: 3000
    health: /health
    resources:
      - database
    environment:
      - SESSION_SECRET
      - STRIPE_API_KEY
    scale:
      count: 2
      cpu: 256
      memory: 512

  worker:
    build: .
    command: node dist/worker.js
    resources:
      - database
    environment:
      - SESSION_SECRET
    scale:
      count: 1
      cpu: 256
      memory: 512

Let us break down what each section accomplishes:

Top-level environment: Variables defined here are available to every service in the application. NODE_ENV=production with a default value ensures your application always runs in production mode. Variables without a value (like SESSION_SECRET) must be set via convox env set and will cause deployment to fail if missing.

Resources: The database resource creates a PostgreSQL instance with an explicit version. This same version runs whether you deploy to a development Rack, staging, or production. No more discovering that staging runs PostgreSQL 14 while production runs PostgreSQL 15.

Services: Each service defines how a component of your application runs. The web service builds from the Dockerfile, exposes port 3000, and includes a health check endpoint. The worker service uses the same build but runs a different command.

Health checks: The health: /health directive tells Convox to verify the service is responding before routing traffic to it. During deployments, new containers must pass health checks before old ones are terminated. This prevents deploying broken code.

For a complete reference of all available options, see the convox.yml documentation.

Managing Environment Variables Properly

Environment variables are one of the most common sources of environment drift. A variable gets added to development, forgotten in staging, and causes a production incident. Convox provides a structured approach to managing variables that prevents this class of errors.

Variables fall into two categories:

Configuration with defaults: Variables like NODE_ENV or LOG_LEVEL that have sensible defaults can be defined directly in convox.yml with their values. These deploy the same way everywhere unless explicitly overridden.

Secrets and environment-specific values: Variables like SESSION_SECRET or STRIPE_API_KEY differ between environments and should never be committed to source control. These are declared in convox.yml without values and set using the CLI.

To set environment variables for an application:

$ convox env set SESSION_SECRET=your-secret-value STRIPE_API_KEY=sk_live_xxx -a myapp
Setting SESSION_SECRET, STRIPE_API_KEY... OK
Release: RABCDEFGHI

Setting environment variables creates a new release. To apply the changes, promote the release:

$ convox releases promote RABCDEFGHI -a myapp
Promoting RABCDEFGHI... OK

To view current environment variables:

$ convox env -a myapp
NODE_ENV=production
LOG_LEVEL=info
SESSION_SECRET=your-secret-value
STRIPE_API_KEY=sk_live_xxx

For detailed information on environment variable management, including interpolation and release-specific variables, see the Environment Variables documentation.

Deploying Across Environments

With your Dockerfile and convox.yml in place, deploying to different environments uses the same command with different targets. This is where the consistency payoff becomes tangible.

First, create your application in each environment:

# Create the app in staging
$ convox apps create myapp -r staging
Creating myapp... OK

# Create the app in production
$ convox apps create myapp -r production
Creating myapp... OK

Set environment-specific variables for each:

# Staging uses test API keys
$ convox env set STRIPE_API_KEY=sk_test_xxx SESSION_SECRET=staging-secret -a myapp -r staging

# Production uses live API keys
$ convox env set STRIPE_API_KEY=sk_live_xxx SESSION_SECRET=production-secret -a myapp -r production

Now deploy the same code to each environment:

# Deploy to staging
$ convox deploy -a myapp -r staging
Packaging source... OK
Uploading source... OK
Starting build... OK
Building: .
...
Build: BABCDEFGHI
Release: RBCDEFGHIJ
Promoting RBCDEFGHIJ... OK

# After testing in staging, deploy to production
$ convox deploy -a myapp -r production
Packaging source... OK
Uploading source... OK
Starting build... OK
...
Promoting RDEFGHIJKL... OK

The critical point here is that both deployments use the same convox.yml and Dockerfile. The Node.js version, PostgreSQL version, health check configuration, and scaling parameters are identical. Only the environment variables differ, and those differences are intentional and explicit.

For more on the deployment process including rolling updates and automatic rollbacks, see the Deploying Changes documentation.

Staging Production Consistency in Practice

Let us walk through a realistic scenario that shows how this workflow prevents the kind of incident we described at the beginning.

Imagine your team decides to upgrade from Node.js 20.10 to Node.js 22.1 to take advantage of performance improvements. In a traditional workflow, this change might happen incrementally: someone updates their local environment, then staging gets updated during a maintenance window, then production follows weeks later.

With the Convox approach, the upgrade happens in one place and propagates everywhere:

# Update Dockerfile
FROM node:22.1.0-alpine AS builder
# ... rest of Dockerfile remains the same

Commit this change and deploy to staging:

$ convox deploy -a myapp -r staging
...
Promoting RNEWRELEASE... OK

Run your test suite against staging. If something breaks due to the Node.js upgrade, you discover it now, not at 2 AM in production. If tests pass, deploy the identical build to production:

$ convox deploy -a myapp -r production
...
Promoting RPRODRELEASE... OK

Both environments now run Node.js 22.1.0. There is no drift. There is no "staging worked but production failed" scenario because they are running the same code, the same runtime, and the same database version.

What This Approach Eliminates

The combination of a well-structured Dockerfile and convox.yml eliminates several categories of production incidents:

Runtime version mismatches: The Dockerfile pins exact versions. Every environment runs the same Node.js, Python, Ruby, or whatever runtime your application requires.

Database version drift: The convox.yml resources section specifies database versions explicitly. No more discovering PostgreSQL version differences in production.

Missing environment variables: Variables declared in convox.yml without defaults must be set before deployment succeeds. You cannot accidentally deploy without required configuration.

Dependency version conflicts: Using npm ci (or equivalent for other languages) with lockfiles ensures the exact same dependency versions everywhere.

Configuration drift: Health check paths, scaling parameters, and service commands are defined once and apply everywhere. Staging cannot accidentally have different health check settings than production.

Beyond Basic Parity

Once you have basic dev staging prod parity working, Convox provides additional features that build on this foundation:

Rolling updates: When you deploy, Convox starts new containers, waits for them to pass health checks, then gradually shifts traffic and terminates old containers. If new containers fail health checks, the deployment rolls back automatically. See the Rolling Updates documentation.

Instant rollbacks: Every deployment creates a release. If something goes wrong, roll back to the previous release with a single command: convox releases rollback RPREVIOUSID -a myapp. See the Rollbacks documentation.

Autoscaling: The scale section in convox.yml can define ranges and targets for automatic scaling based on CPU and memory utilization. See the Autoscaling documentation.

CI/CD integration: The same convox deploy command works in CI/CD pipelines. Your automated deployments use the same workflow as manual ones. See the CI/CD Workflows documentation.

Get Started

Environment drift is not inevitable. With a structured approach to containerization and configuration, you can ensure that code working in development works identically in staging and production. The key is defining your runtime requirements once and applying them everywhere.

The workflow we covered starts with an explicit Dockerfile that pins versions and uses multi-stage builds. It continues with a convox.yml that defines services, resources, and environment variables in a single file. And it finishes with a deployment process that applies this configuration identically to every environment.

Ready to eliminate "works on my machine" from your vocabulary? The Getting Started Guide walks you through creating your first Rack and deploying an application. You can have a staging environment running in under 20 minutes.

Create a free Convox account and deploy your first application today. For teams with compliance requirements or questions about enterprise features, contact our team.

Let your team focus on what matters.