External Databases
By default Potions creates a PostgreSQL database for every app, on the app server or on a dedicated database server. If your database already lives with a managed provider, choose External database instead. Potions stores the connection URL as the app's DATABASE_URL, checks that the app server can reach the database before the app is created, and leaves the rest to your provider: no database is created on your server, nothing is backed up, and nothing is dropped when you delete the app.
Choosing an external database
When you add an app, the Database picker has three choices:
- This server (default): Potions creates the database on the app server.
- A database server: one is listed for each database server on the app server's private network. See Dedicated Database Servers.
- External database (Supabase, Neon, RDS, …): your app connects to a database you run elsewhere.
Choosing External database reveals a Connection URL field. Paste the connection string from your provider's dashboard as it is. Whether the connection is encrypted is set in your app's config, not in the URL; see Encrypting the connection below.
When you click Create app, Potions runs a connection check from the app server before creating anything. If it passes, the app is created. If it fails, the error appears under the field together with a Create anyway button, for the cases where you know why it failed (a provider allowlist you'll fill in next, for example). An app created this way shows a Connection not verified notice on its overview and Database tab until a check passes. You can also click Test connection from this server at any time to check without submitting the form.
The choice is made at creation. An app with a Potions-managed database can't be switched to an external one from the dashboard, and vice versa.
The connection URL
Potions checks the URL before it is stored and explains what to change, but never rewrites it. The rules:
-
It starts with
postgresql://,postgres://orecto://. Ecto accepts all three. -
It includes a user, a password, a host and a database name. The port defaults to
5432. The path has exactly one segment, the database name; a URL with an empty path (Prisma Postgres hands these out) is refused. -
Characters that break URLs must be percent-encoded in the password:
@as%40,/as%2F,?as%3F,#as%23and:as%3A. Apps on Ecto older than 3.14 silently truncate the password at a raw:. -
The host can't be
localhost. To run PostgreSQL on the app server, choose This server instead. - One host only. Ecto uses only the first host of a multi-host URL, so paste a single host.
-
The keyword form (
host=… user=…) isn't accepted. Paste the URL form. -
Supabase's Connect dialog leaves a literal
[YOUR-PASSWORD]placeholder in the string. Replace it with your real password before pasting.
Query parameters
Ecto reads only four query parameters. Everything else is passed to Postgrex as a string, where it is ignored or crashes the app at start, so Potions refuses any other parameter by name.
| Parameter | Value | What it does |
|---|---|---|
ssl |
exactly true or false |
Turns TLS on with Postgrex's defaults. See Encrypting the connection before using it |
pool_size |
integer |
The connection pool size. Overrides the app's POOL_SIZE variable, and Potions warns about that |
timeout |
integer, milliseconds | The query timeout |
idle_interval |
integer, milliseconds | How often idle connections are pinged |
Two parameters are accepted with a warning because every provider's copied URL carries them:
-
sslmode=…is ignored by Ecto. It doesn't turn encryption on or off; your app's config does. -
channel_binding=…is ignored by Ecto today and refused by Postgrex 0.23 and newer. Remove it when convenient.
Potions also warns when the URL looks like a transaction-mode pooler (port 6543, 25061 or 6432, or a -pooler. host). See Transaction-mode poolers.
The URL is stored encrypted, like every other environment variable, written to the app's environment on deploy, never sent to build servers, and redacted from build logs.
The connection check
The check runs on the app server, over SSH, with psql, because that is the only network path that matters: the provider's allowlist sees the server's IP, IPv6 reachability is the server's, and the TLS certificate is judged against the server's own trust store. Potions resolves the host, then connects with a 10 second timeout. It first tries TLS with certificate verification against the server's OS trust store, falls back to TLS without verification only if the certificate isn't trusted, and to plaintext only if the server offers no TLS at all.
When the check succeeds, the button shows a check mark and reads Connected. That is the whole result: the check can't know which ssl: setting your app uses, so it doesn't guess. It adds a line only for two things you can't see from the URL:
Notes on success
| Note | What it means |
|---|---|
| No TLS offered. The connection will be unencrypted. | No app setting can change that; enable TLS at the provider first. |
Connected over IPv6. Potions will set ECTO_IPV6=true for this app. |
The host only has an IPv6 address and the server can reach it. Potions adds the variable so the stock runtime.exs connects over IPv6. |
Failures
| Message | What it means |
|---|---|
<host> only has an IPv6 address and <server> has no IPv6 connectivity. |
DigitalOcean servers have no IPv6 today. Use your provider's IPv4 endpoint (for Supabase, the session pooler on port 5432). |
<host> could not be resolved from <server>. |
The hostname is wrong. Check it against the provider's dashboard. |
The server requires TLS but the handshake failed: <error>. |
Usually a host and port that don't belong together, such as a pooler port on the direct host. |
Authentication failed for user <user>. |
Wrong password, or on Supabase poolers a user that isn't postgres.<project-ref>. |
<host>:<port> did not answer. |
Nothing answered within the connect timeout (10 seconds per resolved address). Almost always the provider's allowlist; the message names the server IP to add. |
The database refused the connection: <error>. |
Anything else, with the provider's own error text. |
Couldn't reach <server> over SSH to run the check. |
A problem between Potions and your server, not with the database. Try again in a moment, or create the app anyway and test from its Database tab. |
Encrypting the connection
Deployment tools built for PHP and Ruby, such as Laravel Forge and Hatchbox, do nothing about database encryption, and their users still get an encrypted connection. That is a property of the driver, not the platform: PHP's and Ruby's PostgreSQL drivers wrap libpq, which tries TLS by default. Postgrex, the driver behind Ecto, defaults to no TLS, has no "use TLS if offered" mode, and ignores sslmode= in the URL. A Phoenix app needs one line in config/runtime.exs to get the same protection.
Add it to your Repo config, in the block that already sets url: database_url, then redeploy:
config :my_app, MyApp.Repo,
url: database_url,
ssl: [verify: :verify_none]
This encrypts the traffic so it can't be read in transit. It doesn't check that the server is really your provider, which is the same protection Forge and Hatchbox users get, and it works with every provider, including Supabase.
Three things to know:
-
Leave
?ssl=trueout of the URL. Ecto 3.12.2 and newer ignores the URL'sssl=truewith a log line when the config setsssl:; older Ecto lets the URL win and replaces your setting with full certificate verification, which fails on Supabase. - OTP 26 and newer logs a warning once per connection, "Server authenticity is not verified since certificate path validation is not enabled". It is expected; it is the reminder that the certificate isn't checked.
-
Postgrex older than 0.18 doesn't accept the list form. Use
ssl: true, ssl_opts: [verify: :verify_none]there, or upgrade Postgrex.
If you also want the certificate verified, so the connection fails when a server impersonates your provider: with a provider on a public certificate authority (Neon, Prisma Postgres, PlanetScale, Azure), use ssl: [cacerts: :public_key.cacerts_get()], which is what Neon's own docs recommend. With a provider on a private authority (Supabase, DigitalOcean, RDS), that setting fails with unknown_ca. Download the provider's CA certificate, commit it under priv/certs/, and use ssl: [cacertfile: Path.join(:code.priv_dir(:my_app), "certs/<provider>-ca.crt")] instead.
Supabase
The Connect dialog offers three strings: the direct connection (db.<project-ref>.supabase.co, port 5432), the session pooler (aws-N-<region>.pooler.supabase.com, port 5432, user postgres.<project-ref>) and the transaction pooler (the same host on port 6543).
- Use the session pooler. It is reachable over IPv4 and supports prepared statements, so a stock Phoenix app works unchanged.
-
The direct host is IPv6-only unless the IPv4 add-on is enabled on your project. From a Hetzner server it works, and Potions sets
ECTO_IPV6=truefor the app. From a DigitalOcean server the check fails with the IPv6 message; use the session pooler. - The transaction pooler needs the pooler checklist.
-
Replace
[YOUR-PASSWORD]with your database password, percent-encoded if it contains@,/,?,#or:. -
Encryption. Supabase accepts unencrypted connections by default, so an app with no
ssl:setting connects in plaintext across the internet without any error. Addssl: [verify: :verify_none]as described in Encrypting the connection. If you also want the certificate verified, download the CA from Project Settings, Database, SSL configuration (prod-ca-2021.crt) and commit it forcacertfile;?ssl=trueandcacerts_get()fail withunknown_cabecause the CA is Supabase's own.
Neon
Copy the connection string from the Connect dialog. Pasted as it is, it carries ?sslmode=require&channel_binding=require; Potions warns that both are ignored, which is expected.
-
Encryption. Neon requires TLS: without
ssl: [verify: :verify_none]inruntime.exs, it rejects the connection with "connection is insecure". Neon's certificate is publicly trusted, sossl: [cacerts: :public_key.cacerts_get()]verifies it if you want that, and on Postgrex 0.21.1 and newer?ssl=trueon the URL does the same with no code change. -
Use the direct host. The
-poolerhost is a transaction-mode pooler. Potions runs your migrations withDATABASE_URL, so point it at the direct host, or see the pooler checklist. -
Scale to zero won't kick in. Postgrex pings each idle connection every second (
idle_interval), so a deployed app keeps the Neon compute awake around the clock. Plan for the compute hours accordingly.
DigitalOcean Managed Databases
Find the connection string under Connection details with Public network selected. It uses port 25060 and the doadmin user, and carries ?sslmode=require, which Potions warns is ignored.
- Trusted sources. Add the app server's public IP (on the server's Overview tab) to the cluster's trusted sources. Until you do, the check reports that the host did not answer, with the IP to add.
-
Connection pools (port
25061) are transaction mode by default. Use the direct port or see the pooler checklist. -
Encryption. TLS is required, and
ssl: [verify: :verify_none]works on both editions. To verify the certificate as well: on the Standard edition click Download CA certificate in the control panel and commit it forcacertfile; on the Advanced edition the CA is public, socacerts_get()or?ssl=trueworks.
Amazon RDS
RDS gives you a host and a port rather than a URL. Assemble one as postgresql://<user>:<password>@<host>:5432/<database>.
-
Reachability. The instance must be publicly accessible, and its security group needs an inbound rule for port
5432from the app server's public IP. -
Encryption. RDS requires TLS on PostgreSQL 15 and newer, and
ssl: [verify: :verify_none]works. To verify the certificate as well, add theaws_rds_castorepackage and setssl: [cacertfile: AwsRdsCAStore.file_path()], or downloadglobal-bundle.pemfrom the RDS documentation and commit it forcacertfile; the CAs are Amazon's own, not public ones.
Transaction-mode poolers
Supabase on port 6543, DigitalOcean connection pools on 25061, Neon -pooler hosts and PgBouncer in transaction mode hand each statement to whichever server connection is free. A stock Phoenix app assumes a connection it keeps, so behind one of these it needs three changes:
-
prepare: :unnamedin the Repo config. Named prepared statements don't survive across pooled connections. -
notifier: Oban.Notifiers.PGin the Oban config.LISTEN/NOTIFYdoesn't pass through a transaction pooler. The same applies to the Postgres adapter forPhoenix.PubSub; use the default adapter. -
Run migrations against the direct host. Ecto takes an advisory lock while migrating, which transaction poolers don't support. Potions runs migrations with the app's
DATABASE_URL, so point it at the direct host or a session pooler. If it must stay on the transaction pooler, setmigration_lock: falsein the Repo config; Potions runs one deploy at a time per server, so nothing else migrates concurrently.
Clusters
An app on an external database can run as a cluster: the Cluster page's database check is satisfied by the external database, the same way a dedicated database server satisfies it. Two things follow from every node connecting to the provider directly, from its own public IP:
- Allowlists need every node. The External database card lists the public IP of the primary and every attached node under Server IPs. Add a server's IP at the provider before attaching it. When you attach a node, the sync deploy that ships the running release checks the connection from that node first, before anything is built; if the database can't be reached from there, the deploy fails naming the node and its IP, nothing changes on the fleet, the node stays out of the load balancer, and the Cluster page shows the failure on the node's row. Add the IP, then Redeploy that deployment from the Deployments page, or detach the node from the Cluster page.
- Test and Change run from every node. On a clustered app, Test connection from every node and the URL change check the connection from every serving node in parallel, and the result lists them one by one. The app counts as verified only when every node connects.
The Cluster page's connection figures (N nodes x POOL_SIZE + one notifier per node, about double during a deploy) are shown without a ceiling, since the limit is your provider's; compare them with it, and prefer the provider's session pooler if you get close.
What Potions doesn't do
The app's Database tab shows an External database card with the host, database and user, and the connection URL masked, with Reveal, Copy and Change. Everything a Potions-managed database gets is left to the provider:
- Backups. Potions doesn't back up external databases, and the tab shows no backups, imports, restores, resets or moves. Use your provider's backups.
- Extensions. Enable them from the provider's dashboard or SQL editor. Trusted extensions in an Ecto migration usually work; the SSH path there doesn't apply.
- Credential rotation. When you rotate the password at the provider, paste the new URL on the Database tab. Potions doesn't rotate it for you.
- Deletion. Deleting the app leaves the external database untouched; the delete dialog says so.
Changing the connection URL
Click Change on the External database card. Potions validates the new URL and runs the connection check from the app server again, with a Save anyway button if it fails. On save, ECTO_IPV6 is set or removed from the check's result, and the Connection not verified notice appears until a check passes.
The change applies on the next deploy, like any other environment variable. On the Environment tab, DATABASE_URL is shown locked with "Managed on the Database tab"; edits and bulk pastes that include it are refused.
Things to know
- Every app server still runs PostgreSQL. An app on an external database doesn't use it, but the service stays installed for other apps on the server.
- The check runs from the app server, not from Potions. Provider allowlists need the server's public IP, shown under Server IP on the External database card (every node's, for a cluster).
-
The check reports what the server offers, not what your app does. Encryption and verification are decided by the
ssl:line in yourruntime.exs. A wrong setting shows up at the migration step of the next deploy; see Troubleshooting Failed Deploys.