Series: Building Backarch — Engineering decisions from building backarch.com
The error is FATAL: no pg_hba.conf entry for host. You've added ?ssl=require to the connection URL. The application boots and passes its health check. Alembic still fails with the same error.
These are not the same problem. They're one parameter name, spelled differently by two drivers that claim to speak the same protocol.
Why RDS rejects what your local database accepts
Local PostgreSQL installed via Docker or Homebrew defaults to trusting local connections. It accepts unencrypted connections without complaint.
RDS PostgreSQL 16 is configured differently — it requires TLS for all inbound connections, and the pg_hba.conf is managed by AWS, not you. The first connection attempt from an application without TLS negotiated gets:
FATAL: no pg_hba.conf entry for host "10.0.x.x", user "backarch",
database "backarch", no encryption

The same enforcement applies to Cloud SQL (GCP) and Azure Database for PostgreSQL when SSL is required — the error message differs slightly but the root cause is identical: no TLS on the wire, connection rejected.
Adding ?ssl=require to the asyncpg connection string fixes it for the application layer:
DATABASE_URL = "postgresql+asyncpg://host.rds.amazonaws.com:5432/db?ssl=require" # credentials omittedasyncpg reads ssl. Connection succeeds. The application boots, health check passes, and you think you're done. Then you run alembic upgrade head against RDS and get the same FATAL error.
The parameter that changed names
psycopg2 doesn't read ssl. It reads sslmode. Two different keys, defined independently by two driver libraries, for the same underlying TLS negotiation. Neither is wrong — DSN query parameters are not standardised across PostgreSQL client libraries. The wire protocol is standardised; the connection string format each client uses to configure its own behaviour is not.
Alembic uses psycopg2 for synchronous migration operations. It cannot use asyncpg directly. So in alembic/env.py, the URL needs translation before Alembic sees it:
from sqlalchemy.engine import make_url
def get_sync_url() -> str:
url = make_url(settings.DATABASE_URL)
# Strip the asyncpg driver — Alembic needs a synchronous connection
sync_url = url.set(drivername="postgresql")
# Translate ssl= (asyncpg) → sslmode= (psycopg2)
query = dict(sync_url.query)
query.pop("ssl", None)
query["sslmode"] = "require"
return str(sync_url.set(query=query))Now each driver gets what it expects. asyncpg reads ssl=require from DATABASE_URL. Alembic's psycopg2 reads sslmode=require from the translated URL constructed in env.py.
make_url() + url.set() is safer than string replacement. A naive .replace("+asyncpg", "").replace("ssl=", "sslmode=") on a URL with special characters in the password fails in ways that are hard to debug. The SQLAlchemy URL object handles encoding correctly and doesn't break on edge cases.
Why this never surfaces in tests
Local Docker PostgreSQL accepts unencrypted connections. CI containers also accept unencrypted connections. The only environment that enforces SSL is RDS — which means you can write this bug, pass every test, merge to main, and ship it.
This is not unique to RDS. Cloud SQL in GCP and Azure Database for PostgreSQL both have configurable SSL enforcement. If your test environment doesn't mirror that configuration, the bug is invisible until first production deploy.
The only reliable check is running alembic current against your actual RDS instance before the first production deploy:
alembic current
# If it prints a revision hash → both the connection and TLS negotiation worked
# If it prints FATAL → driver mismatchThis check belongs in the deploy runbook, not discovered at 11pm on first deploy.

The documentation gap
The asyncpg connection reference documents ssl. The psycopg2 libpq documentation documents sslmode. The SQLAlchemy docs note that query parameters are passed through to the underlying driver without transformation. None of them have a comparison table saying "these two drivers use different parameter names for the same setting" — because each driver's documentation only covers its own parameters, and the gap between them is nobody's problem to document.
The fix itself is five lines. The discovery cost is a failed production deploy that looks like a network or credentials problem until you read the error message carefully enough to distinguish "no SSL configured" from "wrong credentials." That distinction is not obvious from the error text alone.
It's the kind of thing you find exactly once, on your first production deploy, when everything else is already working.
At Backarch, the application health check was green while the Alembic step in the deploy script was silently failing. After the fix, both the ?ssl=require driver translation and the alembic current verification step were added to the deploy runbook. It's now the first thing checked on any new environment.
Two drivers, one protocol, two parameter names. The workaround is five lines in alembic/env.py. The cost of not knowing about it is a failed production deploy that looks like a network or credentials problem until you read the error message carefully enough.
Next: Calling synchronous SDKs from async Python — how to call boto3 from an asyncio event loop without blocking every other request.
