The usual causes of works-on-my-machine bugs in backend services, the fixes that close each gap, and how to run the same infrastructure locally that production uses.
"Works on my machine" is the oldest joke in software, and in backend teams it usually means something specific: the service passed every test on a laptop and then failed in staging because the laptop used SQLite instead of Postgres, a newer Node version, an environment variable nobody else had, or a queue that was replaced by a function call. The bug is real, but the code was never tested against the environment it runs in.
Each of those gaps has a fix, and most of them come down to the same idea, which the Twelve-Factor App calls dev/prod parity: keep the local environment as close to production as you can, in the places that matter.
Different Node, Python or Go versions cause a large share of these bugs, from a new language feature that the CI image does not support to a lock file resolved differently. Pin the toolchain in the repository rather than in a wiki: the toolchain line in go.mod makes the go command download the right version, packageManager in package.json fixes the npm, pnpm or Yarn version, and .nvmrc or .tool-versions cover the rest. Commit lock files and install with npm ci or the equivalent so CI and laptops resolve the same dependency tree.
A value that exists in one developer's shell profile and nowhere else is a classic cause. Keep configuration per environment, load secrets from a secret manager rather than a .env file passed around in chat, and validate required values at startup, so a missing value fails immediately with a clear error instead of halfway through a request in staging.
Swapping Postgres for SQLite or a queue for a direct function call makes local setup easy and hides exactly the behavior that breaks in production: SQL dialect differences, transaction isolation, message redelivery, ordering and timeouts. Run the real thing locally instead, with Docker Compose for long-running services or Testcontainers to start a database per test run, and treat local mocks of cloud APIs as a last resort, since a mock only knows the behavior its author thought of.
Dev Containers describe the whole development environment, including the OS image, toolchain and editor extensions, in a file in the repository, which removes most differences between developers and between laptops and CI. Containers do not fix configuration, data or IAM, though, and a dev container that runs the application against a mocked queue has the same gap as a laptop.
The environment that matters is the one the code is deployed to, so the last line of defense is running each change somewhere that looks like production before it merges. CI should run tests against real databases with migrations applied, and a preview environment per pull request lets reviewers and end-to-end tests hit a deployed copy with its own infrastructure.
Run in your terminal to get started locally.
Encore is an open source backend toolkit for TypeScript and Go, with a platform that provisions what your services declare into your own AWS or GCP account. Infrastructure is declared in the service code, and that one declaration drives every environment: encore run starts Postgres and Pub/Sub locally with migrations applied, and a deploy provisions RDS with SNS and SQS on AWS, or Cloud SQL and Pub/Sub on GCP, from the same code, so the local and cloud setups come from one source.

Here a Go service writes signups to Postgres and another subscription sends a welcome email, which locally goes through the same publish, retry and subscribe path as in production:
// users/users.go
package users
import (
"context"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("users", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
type SignupEvent struct {
UserID int64
Email string
}
var Signups = pubsub.NewTopic[*SignupEvent]("signups", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
type SignupParams struct {
Email string
}
type User struct {
ID int64
}
//encore:api public method=POST path=/signup
func Signup(ctx context.Context, p *SignupParams) (*User, error) {
var id int64
err := db.QueryRow(ctx,
`INSERT INTO users (email) VALUES ($1) RETURNING id`, p.Email).Scan(&id)
if err != nil {
return nil, err
}
if _, err := Signups.Publish(ctx, &SignupEvent{UserID: id, Email: p.Email}); err != nil {
return nil, err
}
return &User{ID: id}, nil
}
var _ = pubsub.NewSubscription(Signups, "welcome-email",
pubsub.SubscriptionConfig[*SignupEvent]{
Handler: func(ctx context.Context, e *SignupEvent) error {
// Send the email. Delivery is at least once, so record sent emails
// by UserID and skip ones that were already sent.
return nil
},
},
)
The migration in users/migrations/1_create_users.up.sql creates the table:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
encore test runs your Go or TypeScript tests against a separate test database with the migrations applied, the local development dashboard shows a trace for every request and message, and secrets are set per environment type with encore secret set, so a missing value is a deploy error rather than a staging surprise. With the GitHub integration, each push deploys the same code through the same pipeline to the cloud environments. Cron jobs do not run on their schedule locally, and you call the cron endpoint manually to test it, as the cron jobs docs describe.
An agent that adds a topic and its subscriber can run the change against real local infrastructure in the same session, instead of handing over code that was only checked against a mock.
Best fit: backend teams who want their laptops, CI and cloud environments to run from the same infrastructure declarations in their own AWS or GCP account.
Pin the toolchain and lock files first, since that is a one-time change that removes a whole category of failures. Replace local substitutes for the database and queue next, then add startup validation for config and a preview environment per pull request. If most of your gaps are between local services and cloud resources, Encore derives both from the same code.
It describes code that runs correctly on a developer's computer but fails in CI, staging or production, because something about the local environment, such as a runtime version, a config value, a local service or the data, differs from where the code actually runs.
Pin runtime and dependency versions in the repository, keep configuration and secrets per environment instead of in local files, run the same kind of database and queue locally that production uses, and make CI and preview environments the place where changes are proven.
Containers fix differences in the runtime and system libraries, which covers many cases. They do not fix differences in configuration, data, cloud services that are mocked locally, or IAM permissions that only exist in the cloud.
With Encore, declare them in the service code with sqldb.NewDatabase and pubsub.NewTopic. encore run starts Postgres and an in-memory Pub/Sub implementation locally, and the same declarations become RDS with SNS and SQS on AWS, or Cloud SQL and Pub/Sub on GCP, when you deploy.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.