AWS RDS Extensions in the Atlas Dev Database
Your application schema uses one of RDS PostgreSQL's AWS extensions: say a trigger
that calls aws_lambda.invoke, or a routine that loads data through
aws_s3.table_import_from_s3. It works on RDS. But any Atlas command that relies on a
dev database, like migrate diff, schema diff, or schema plan,
can't compile that schema, because a vanilla postgres Docker image doesn't have those
extensions.
This is the question RDS users ask us most:
How do you handle the AWS extensions (
aws_commons,aws_lambda) when they aren't available in the dev Docker image?
The answer is to mock them: give the dev database just enough of each extension to compile your schema, while the real implementations keep running on RDS. We'll use the AWS extensions as the worked example, but the pattern fits any cloud-proprietary extension with no public binary.
Why the dev database can't compile it
Most Atlas commands need a dev database: a temporary, isolated database Atlas spins up to compile and validate your schema before planning any change. Parsing the SQL is straightforward. The harder part is confirming that a default, an expression, or a function call actually works, and only a real PostgreSQL engine running your target's exact version can do that.
RDS PostgreSQL ships extensions that live nowhere else: aws_commons, aws_lambda,
and aws_s3. They let your schema reach into AWS: aws_lambda.invoke in a trigger, an
aws_s3 import, and so on. But they have no public binary.
You can't CREATE EXTENSION aws_lambda on a vanilla postgres image, because the code
isn't there. So when Atlas loads your schema onto the dev database and reaches the
first reference, PostgreSQL refuses it.
There are two ways around this. The heavy one is to point Atlas at a database that
already has the real extensions installed: a dev block can
target any running PostgreSQL, so many teams use a second RDS instance. It works, but
it's slow to start and costs money to keep running.
There's a lighter way. At plan time Atlas never runs aws_lambda.invoke; it only needs
the function to exist so the trigger, view, or default around it compiles. That means you
don't need the real extension on any database. Instead, hand a throwaway Docker container
a set of stubs: functions with the right signatures but empty bodies, just enough to
compile. That's the default this guide uses: no second RDS instance to provision, nothing
to install.
Prerequisites
- Docker, to run the ephemeral dev-database container
- Atlas installed on your machine:
- macOS + Linux
- Homebrew
- Docker
- Windows
- CI
- Manual Installation
To download and install the latest release of the Atlas CLI, simply run the following in your terminal:
curl -sSf https://atlasgo.sh | sh
Get the latest release with Homebrew:
brew install ariga/tap/atlas
To pull the Atlas image and run it as a Docker container:
docker pull arigaio/atlas
docker run --rm arigaio/atlas --help
If the container needs access to the host network or a local directory, use the --net=host flag and mount the desired
directory:
docker run --rm --net=host \
-v $(pwd)/migrations:/migrations \
arigaio/atlas migrate apply \
--url "mysql://root:pass@:3306/test"
Download the latest release and move the atlas binary to a file location on your system PATH.
GitHub Actions
Use the setup-atlas action to install Atlas in your GitHub Actions workflow:
- uses: ariga/setup-atlas@v0
with:
cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }}
Other CI Platforms
For other CI/CD platforms, use the installation script. See the CI/CD integrations for more details.
The docker and dev blocks used throughout this guide are available to
Atlas Pro users. To use them, run:
atlas login
This guide builds the following project layout:
.
├── atlas.hcl
├── schema.sql # your desired schema (calls the extension functions)
├── stubs/
│ └── aws.sql # the dev-database stubs (from this guide)
└── migrations/
Stub the AWS extensions
Here's a schema that leans on all three extensions: a trigger that invokes a Lambda on insert, and a helper that bulk-loads rows from S3:
CREATE TABLE "public"."events" (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"payload" jsonb NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now()
);
-- On insert, invoke a Lambda function via the aws_lambda extension.
CREATE FUNCTION "public"."on_event_insert"() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM aws_lambda.invoke(
aws_commons.create_lambda_function_arn('process-event', 'us-east-1'),
to_jsonb(NEW)
);
RETURN NEW;
END;
$$;
CREATE TRIGGER "trg_on_event_insert"
AFTER INSERT ON "public"."events"
FOR EACH ROW EXECUTE FUNCTION "public"."on_event_insert"();
-- Bulk-load rows from S3 via the aws_s3 extension.
CREATE FUNCTION "public"."import_events"("bucket" text, "path" text) RETURNS text
LANGUAGE sql AS $$
SELECT aws_s3.table_import_from_s3(
'public.events', '', '(format csv)',
aws_commons.create_s3_uri("bucket", "path", 'us-east-1')
);
$$;
Atlas compiles this on a dev database before it plans anything. On a vanilla postgres
image the AWS extensions are missing, so the compile stops at the first reference:
Error: schema.sql:24: pq: schema "aws_commons" does not exist at position 5:9 (3F000)
The fix is to run a baseline of stubs when the dev database starts, so those references
resolve. The baseline attribute of the
docker block is an init script Atlas runs right after the container starts, meant
for exactly "objects not managed by you, such as PostgreSQL extensions or external
functions used in your schema." Point it at a stub file:
docker "postgres" "dev" {
image = "postgres:18" // match your RDS PostgreSQL major version
baseline = file("stubs/aws.sql")
}
env "local" {
src = "file://schema.sql"
dev = docker.postgres.dev.url
migration {
dir = "file://migrations"
}
}
Each stub recreates a function with its real signature but an empty body, so PostgreSQL
accepts the call without the real extension ever being installed. Take
aws_commons.create_s3_uri: RDS's version builds a URI struct from a bucket and path,
but the stub just returns NULL with the same signature and return type:
CREATE FUNCTION aws_commons.create_s3_uri(bucket text, file_path text, region text DEFAULT NULL)
RETURNS aws_commons._s3_uri_1 LANGUAGE sql AS $$ SELECT NULL::aws_commons._s3_uri_1 $$;
The full file applies the same idea to all three extensions:
CREATE SCHEMA IF NOT EXISTS aws_commons;
CREATE SCHEMA IF NOT EXISTS aws_lambda;
CREATE SCHEMA IF NOT EXISTS aws_s3;
-- Composite types (array types are auto-created). Create BEFORE the functions.
CREATE TYPE aws_commons._s3_uri_1 AS (bucket text, file_path text, region text);
CREATE TYPE aws_commons._lambda_function_arn_1 AS (function_name text, region text);
CREATE TYPE aws_commons._aws_credentials_1 AS (access_key text, secret_key text, session_token text);
-- aws_commons constructors
CREATE FUNCTION aws_commons.create_s3_uri(bucket text, file_path text, region text DEFAULT NULL)
RETURNS aws_commons._s3_uri_1 LANGUAGE sql AS $$ SELECT NULL::aws_commons._s3_uri_1 $$;
CREATE FUNCTION aws_commons.create_lambda_function_arn(function_name text, region text DEFAULT NULL)
RETURNS aws_commons._lambda_function_arn_1 LANGUAGE sql AS $$ SELECT NULL::aws_commons._lambda_function_arn_1 $$;
CREATE FUNCTION aws_commons.create_aws_credentials(access_key text, secret_key text, session_token text)
RETURNS aws_commons._aws_credentials_1 LANGUAGE sql AS $$ SELECT NULL::aws_commons._aws_credentials_1 $$;
-- aws_lambda.invoke
CREATE FUNCTION aws_lambda.invoke(function_name aws_commons._lambda_function_arn_1, payload jsonb,
invocation_type text DEFAULT 'RequestResponse', log_type text DEFAULT 'None', context jsonb DEFAULT NULL,
qualifier varchar DEFAULT NULL, OUT status_code integer, OUT payload jsonb, OUT executed_version text, OUT log_result text)
LANGUAGE sql AS $$ SELECT NULL::integer, NULL::jsonb, NULL::text, NULL::text $$;
CREATE FUNCTION aws_lambda.invoke(function_name aws_commons._lambda_function_arn_1, payload json,
invocation_type text DEFAULT 'RequestResponse', log_type text DEFAULT 'None', context json DEFAULT NULL,
qualifier varchar DEFAULT NULL, OUT status_code integer, OUT payload json, OUT executed_version text, OUT log_result text)
LANGUAGE sql AS $$ SELECT NULL::integer, NULL::json, NULL::text, NULL::text $$;
CREATE FUNCTION aws_lambda.invoke(function_name text, payload jsonb, region text DEFAULT NULL,
invocation_type text DEFAULT 'RequestResponse', log_type text DEFAULT 'None', context jsonb DEFAULT NULL,
qualifier varchar DEFAULT NULL, OUT status_code integer, OUT payload jsonb, OUT executed_version text, OUT log_result text)
LANGUAGE sql AS $$ SELECT NULL::integer, NULL::jsonb, NULL::text, NULL::text $$;
CREATE FUNCTION aws_lambda.invoke(function_name text, payload json, region text DEFAULT NULL,
invocation_type text DEFAULT 'RequestResponse', log_type text DEFAULT 'None', context json DEFAULT NULL,
qualifier varchar DEFAULT NULL, OUT status_code integer, OUT payload json, OUT executed_version text, OUT log_result text)
LANGUAGE sql AS $$ SELECT NULL::integer, NULL::json, NULL::text, NULL::text $$;
-- aws_s3 import/export (both overloads each)
CREATE FUNCTION aws_s3.table_import_from_s3(table_name text, column_list text, options text,
bucket text, file_path text, region text, access_key text DEFAULT NULL, secret_key text DEFAULT NULL, session_token text DEFAULT NULL)
RETURNS text LANGUAGE sql AS $$ SELECT NULL::text $$;
CREATE FUNCTION aws_s3.table_import_from_s3(table_name text, column_list text, options text,
s3_info aws_commons._s3_uri_1, credentials aws_commons._aws_credentials_1 DEFAULT NULL)
RETURNS text LANGUAGE sql AS $$ SELECT NULL::text $$;
CREATE FUNCTION aws_s3.query_export_to_s3(query text, bucket text, file_path text, region text DEFAULT NULL,
options text DEFAULT NULL, kms_key text DEFAULT NULL, OUT rows_uploaded bigint, OUT files_uploaded bigint, OUT bytes_uploaded bigint)
LANGUAGE sql AS $$ SELECT NULL::bigint, NULL::bigint, NULL::bigint $$;
CREATE FUNCTION aws_s3.query_export_to_s3(query text, s3_info aws_commons._s3_uri_1 DEFAULT NULL,
options text DEFAULT NULL, kms_key text DEFAULT NULL, OUT rows_uploaded bigint, OUT files_uploaded bigint, OUT bytes_uploaded bigint)
LANGUAGE sql AS $$ SELECT NULL::bigint, NULL::bigint, NULL::bigint $$;
With the baseline in place, the same migrate diff compiles the schema and writes a
clean migration:
atlas migrate diff initial --env local
-- Create "events" table
CREATE TABLE "public"."events" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"payload" jsonb NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- Create "on_event_insert" function
CREATE FUNCTION "public"."on_event_insert" () RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM aws_lambda.invoke(
aws_commons.create_lambda_function_arn('process-event', 'us-east-1'),
to_jsonb(NEW)
);
RETURN NEW;
END;
$$;
-- Create trigger "trg_on_event_insert"
CREATE TRIGGER "trg_on_event_insert" AFTER INSERT ON "public"."events" FOR EACH ROW EXECUTE FUNCTION "public"."on_event_insert"();
-- Create "import_events" function
CREATE FUNCTION "public"."import_events" ("bucket" text, "path" text) RETURNS text LANGUAGE sql AS $$
SELECT aws_s3.table_import_from_s3(
'public.events', '', '(format csv)',
aws_commons.create_s3_uri("bucket", "path", 'us-east-1')
);
$$;
Notice what isn't there: none of the stubbed schemas. Because they live in the dev
database's baseline (its starting state), they sit on both sides of every diff and
cancel out. Atlas never creates or drops them, and a second diff confirms it:
atlas migrate diff followup --env local
The migration directory is synced with the desired state, no changes to be made
That cancellation is why the stubs belong in baseline, not atop schema.sql. Paste
them there and Atlas treats them as yours to manage, re-creating them on every plan.
Use a PostGIS image for binary extensions
Not every extension is missing a binary. PostGIS and pgvector are ordinary open-source
extensions with public images, so the binaries exist and you shouldn't stub them. Do the
opposite: give the dev database an image that has PostGIS, and layer the AWS stubs on
top with the same baseline.
Given a new schema that needs both a PostGIS geometry column and an aws_lambda
trigger:
-- PostGIS is a real binary extension: declaring it enables the geometry type in the
-- build-block image (a harmless no-op in the pre-built image, which already has it).
-- The AWS functions stay stubbed.
CREATE EXTENSION IF NOT EXISTS "postgis";
CREATE TABLE "public"."locations" (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"name" text NOT NULL,
"geom" geometry(Point, 4326) NOT NULL
);
CREATE FUNCTION "public"."on_location_insert"() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM aws_lambda.invoke(
aws_commons.create_lambda_function_arn('geocode', 'us-east-1'),
to_jsonb(NEW)
);
RETURN NEW;
END;
$$;
CREATE TRIGGER "trg_on_location_insert"
AFTER INSERT ON "public"."locations"
FOR EACH ROW EXECUTE FUNCTION "public"."on_location_insert"();
Reuse the same stubs/aws.sql and point the docker block at a PostGIS image. Only the
docker block changes below; the env "local" block stays exactly as before. There are
two ways to get PostGIS in, depending on how tightly you need to pin the version:
- Pre-built PostGIS image
- Build block
The postgis/postgis images ship PostGIS
pre-installed, and the tag pins the version. Find the versions to match on your RDS
instance:
SELECT current_setting('server_version_num'); -- e.g. 180003 -> PostgreSQL 18
SELECT default_version FROM pg_available_extensions -- e.g. 3.4.x
WHERE name = 'postgis';
Then use the matching tag (16-3.4 is just an example):
docker "postgres" "dev" {
// Use the tag matching your RDS PostgreSQL/PostGIS versions.
image = "postgis/postgis:16-3.4"
baseline = file("stubs/aws.sql")
}
For a specific base image or custom combination, a
build block installs the
package yourself:
docker "postgres" "dev" {
// Name Atlas assigns to the image it builds locally. It need not exist on
// any registry.
image = "postgres:16-postgis"
build {
context = "."
dockerfile_inline = <<-EOF
FROM postgres:16
RUN apt-get update \
&& apt-get install -y postgresql-16-postgis-3 \
&& rm -rf /var/lib/apt/lists/*
EOF
}
baseline = file("stubs/aws.sql")
}
apt-get install postgresql-16-postgis-3 installs whatever PostGIS version the
package repository currently ships (for example, 3.6.4), which may not match your RDS
instance. If they differ, Atlas can raise a has no installation script nor update path for version error. Pin a version by preferring the pre-built image tag, or by
installing a specific package version. See
Extension version mismatch for the options.
Either way the schema compiles: geometry(Point, 4326) resolves against the real
PostGIS, and aws_lambda.invoke(...) against the stubs. The one visible difference is
whether PostGIS appears in your migration, which depends on how it reached the dev
database.
With the pre-built image, PostGIS is already installed. It behaves like the AWS stubs: present in the baseline and declared in your schema, so it cancels out and stays out of the plan. The migration contains only your application objects:
-- Create "locations" table
CREATE TABLE "public"."locations" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"name" text NOT NULL,
"geom" public.geometry(Point,4326) NOT NULL,
PRIMARY KEY ("id")
);
-- Create "on_location_insert" function
CREATE FUNCTION "public"."on_location_insert" () RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM aws_lambda.invoke(
aws_commons.create_lambda_function_arn('geocode', 'us-east-1'),
to_jsonb(NEW)
);
RETURN NEW;
END;
$$;
-- Create trigger "trg_on_location_insert"
CREATE TRIGGER "trg_on_location_insert" AFTER INSERT ON "public"."locations" FOR EACH ROW EXECUTE FUNCTION "public"."on_location_insert"();
One catch: since this migration carries no CREATE EXTENSION postgis, PostGIS must
already be enabled on your RDS target. It's available there but not installed by default,
so a role in rds_superuser runs CREATE EXTENSION postgis once; Atlas then manages only
the geometry columns, never the extension itself.
The build block changes the dev database, and through it the migration. It installs the
binary but doesn't pre-create the extension, so your schema's
CREATE EXTENSION IF NOT EXISTS "postgis" runs on the dev database, Atlas starts managing
it, and the migration now leads with it (recording whatever version the dev package
shipped):
-- Create extension "postgis"
CREATE EXTENSION "postgis" WITH SCHEMA "public" VERSION "3.6.4";
-- Create "locations" table
-- ... (same table, function, and trigger as above)
But moving CREATE EXTENSION postgis into the migration changes where it runs, not who is
allowed to run it. The build block reaches RDS only through this generated file, so two
things must hold on the target, or migrate apply fails:
- Privilege.
CREATE EXTENSION postgison RDS requires a role inrds_superuser(PostGIS is not a trusted extension). A least-privilege deploy role like theatlas_migratorused above (a member ofrds_iam, not a superuser, withoutCREATEon the database) cannot create it. The build block does not remove the need for a privileged role; it shifts that need intomigrate apply. - Version. The migration pins the dev image's PostGIS version (here
3.6.4), so RDS must offer that exact version. If RDS ships only, say,3.6.1, thenCREATE EXTENSION postgis VERSION "3.6.4"fails with the samehas no installation script nor update path for versionerror the caution above flags, only now on the target.
So reach for the build block when a privileged role applies the migration and the versions line up; otherwise prefer the pre-built image and have a superuser enable PostGIS on RDS once.
Managing the extension object
Everything so far assumes your schema only calls the extensions' functions. Managing one
is a different case: if your schema also declares CREATE EXTENSION aws_lambda (or
extension "aws_lambda" {}), Atlas tries to create the extension on the dev database, and
for a cloud-only extension there's nothing to create:
Error: schema.sql:1: pq: extension "aws_lambda" is not available (0A000)
You might try keeping the declaration and adding exclude = ["*[type=extension]"]. That
doesn't work: the CREATE EXTENSION runs while Atlas loads your schema onto the dev
database, ahead of any exclude filtering, so the load fails first.
The fix is to stop managing it. RDS owns these extensions, and your stubs already provide
the functions your schema calls, so drop the CREATE EXTENSION line and just call them,
the way the earlier setup does. That plans cleanly.
That leaves the mirror image on the target: once the extension is enabled on RDS, Atlas sees it during inspection, and since your schema no longer declares it, Atlas would plan to drop it. Tell Atlas to leave target extensions alone:
env "rds" {
url = "postgres://..." // your RDS instance
dev = docker.postgres.dev.url // dev database with the stubs
// Do not manage extension objects; RDS owns them.
exclude = ["*[type=extension]"]
}
Narrow it to one extension with exclude = ["aws_lambda[type=extension]"]. The same
lever handles PostGIS version drift: use version = "ANY" on the extension block, or
exclude = ["*[type=extension].version"]. The
extension management and
version mismatch FAQs go deeper.
Configure the RDS environment
Point one env "rds" at the real database for both commands, with
data "aws_rds_token" supplying a short-lived
IAM token:
variable "endpoint" {
// host:port, e.g. mydb.abc123.us-east-1.rds.amazonaws.com:5432
type = string
}
variable "region" {
type = string
default = "us-east-1"
}
variable "database" {
type = string
}
variable "username" {
type = string
default = "atlas_migrator"
}
docker "postgres" "dev" {
image = "postgres:18"
baseline = file("stubs/aws.sql")
}
data "aws_rds_token" "migrator" {
region = var.region
endpoint = var.endpoint
username = var.username
}
env "rds" {
src = "file://schema.sql"
url = "postgres://${var.username}:${urlescape(data.aws_rds_token.migrator)}@${var.endpoint}/${var.database}?sslmode=require"
dev = docker.postgres.dev.url
migration {
dir = "file://migrations"
}
// RDS owns the cloud-only extensions; do not try to manage them.
exclude = ["*[type=extension]"]
}
endpoint and database have no defaults; pass them each run:
atlas migrate diff initial --env rds \
--var endpoint=mydb.abc123.us-east-1.rds.amazonaws.com:5432 \
--var database=appdb
atlas migrate apply --env rds \
--var endpoint=mydb.abc123.us-east-1.rds.amazonaws.com:5432 \
--var database=appdb
aws_rds_token signs the token from your ambient AWS credentials, so IAM auth must be
configured for the database user, see the
RDS IAM migration role guide.
Apply this to other extensions
The AWS extensions are just the recurring example; the technique is general. Whenever an extension has no public binary but your schema calls its functions:
First, find what you use. Grep for the extension's qualified calls (aws_lambda.,
aws_s3.) and stub only those. Then read the signatures from the source of truth rather
than the docs: a database that has the extension installed. The catalog renders them exactly
as CREATE FUNCTION needs, with OUT parameters, defaults, and all:
SELECT format('%I.%I', n.nspname, p.proname) AS func,
pg_get_function_arguments(p.oid) AS args,
pg_get_function_result(p.oid) AS returns
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname IN ('aws_commons', 'aws_lambda', 'aws_s3')
ORDER BY 1, 2;
And the composite types the same way:
SELECT format('%I.%I', n.nspname, t.typname) AS type,
string_agg(a.attname || ' ' || format_type(a.atttypid, a.atttypmod), ', '
ORDER BY a.attnum) AS fields
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
JOIN pg_class c ON c.oid = t.typrelid
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
WHERE n.nspname = 'aws_commons' AND t.typtype = 'c'
GROUP BY 1
ORDER BY 1;
Run these against RDS (swap in your schema names) for the exact shape to mirror. For
the AWS extensions on RDS PostgreSQL 18 they return the eleven functions and three
composite types in stubs/aws.sql (ignore any extra internal functions). The types,
for example:
type | fields
------------------------------------+------------------------------------------------------
aws_commons._aws_credentials_1 | access_key text, secret_key text, session_token text
aws_commons._lambda_function_arn_1 | function_name text, region text
aws_commons._s3_uri_1 | bucket text, file_path text, region text
From there it's mechanical: recreate each object with its real signature and a no-op
body (SELECT NULL::<return type>, OUT parameters intact), wire the file into
baseline, and, if you were managing the extension object, stop and exclude
extensions on the RDS env. Functions and composite types cover most schemas; operators
or casts stub the same way.
The stubs only exist at dev time. Atlas compiles and diffs against them, while the real functions keep running on RDS, untouched during planning. An empty body works because nothing at plan time ever calls it for real.
References
- Dev Database: how Atlas uses a dev database, and the
baselineattribute. - Extension version mismatch: resolving PostGIS/extension version errors.
- Managing PostgreSQL Extensions: managing extensions in a dedicated process.
- Using PostgreSQL Extensions with Atlas: declaring extensions in your schema.
- RDS IAM migration role: Atlas + Terraform + IAM on RDS PostgreSQL.
- Amazon RDS:
aws_lambdaandaws_s3import / export.