What DigitalOcean Manages and What It Does Not

The managed layer covers OS patching, minor version upgrades, automated backups with point-in-time recovery, and standby failover. You do not get access to the underlying Droplet, cannot modify kernel parameters, and cannot install extensions that are not on DigitalOcean's approved list for PostgreSQL.

The approved extension list is the first thing that trips up migrations from self-managed clusters. As of 2026, PostGIS 3.3, pg_stat_statements, pgcrypto, uuid-ossp, and hstore are supported. pg_cron is supported on PostgreSQL 15 and 16. Extensions like pg_partman require manual workarounds because the superuser role is not granted. You connect as doadmin, which has broad but not full superuser privileges.

For MySQL, you get no SSH access and cannot load plugins directly. The binlog format is fixed to ROW, which matters if you are running custom replication downstream. Redis is available without cluster mode on lower tiers; Redis Cluster is only available at the $60/month entry point for multi-node setups.

The managed service is genuinely hands-off for teams that fit within those boundaries. For teams that do not, the constraints accumulate quickly.

# Check which extensions are available before migrating
psql "postgresql://doadmin:PASSWORD@db-postgresql-nyc3-XXXX.db.ondigitalocean.com:25060/defaultdb?sslmode=require" \
  -c "SELECT name, default_version, installed_version FROM pg_available_extensions ORDER BY name;"

Provisioning: API vs Control Panel

The control panel is fine for one-off clusters. For anything repeatable, use the API or Terraform. DigitalOcean's Terraform provider (digitalocean/digitalocean, version 2.39.0 as of this writing) has a stable `digitalocean_database_cluster` resource that covers all engine types.

We provisioned a three-node PostgreSQL 16 cluster in the AMS3 region with the following Terraform config. Node size `db-s-2vcpu-4gb` maps to the $50/month single-node plan; the HA cluster with three nodes at that size runs $150/month plus a $50/month standby node fee depending on region pricing.

The `maintenance_window` block is worth setting explicitly. If you omit it, DigitalOcean picks a window automatically and minor version upgrades happen without warning. We had a cluster upgrade from PostgreSQL 16.1 to 16.2 during business hours before we locked down the window.

resource "digitalocean_database_cluster" "pg_prod" {
  name       = "pg-prod-ams3"
  engine     = "pg"
  version    = "16"
  size       = "db-s-2vcpu-4gb"
  region     = "ams3"
  node_count = 3

  maintenance_window {
    day  = "sunday"
    hour = "02:00:00"
  }

  tags = ["production", "postgres"]
}

resource "digitalocean_database_firewall" "pg_prod_fw" {
  cluster_id = digitalocean_database_cluster.pg_prod.id

  rule {
    type  = "droplet"
    value = digitalocean_droplet.app_server.id
  }
}

Connection Handling and PgBouncer

Every DigitalOcean managed PostgreSQL cluster ships with PgBouncer exposed on port 25061 alongside the direct PostgreSQL port 25060. This is one of the more useful defaults in the service. PgBouncer runs in transaction pooling mode, which means your application can open hundreds of connections that multiplex onto a smaller backend pool.

On our `db-s-2vcpu-4gb` nodes, `max_connections` is set to 97 by default - you cannot change this at the PostgreSQL level. The PgBouncer pool on the same node handles up to 10,000 client connections and maintains a 20-connection backend pool per database by default. We adjusted the pool size through the DigitalOcean API because the control panel does not expose this setting.

Applications that use prepared statements have a problem here. PgBouncer in transaction mode does not support named prepared statements. If your ORM uses `PREPARE`, connect to port 25060 directly and manage your own pooling, or switch to unnamed prepared statements where supported.

# Connect via PgBouncer (transaction pooling, port 25061)
psql "postgresql://doadmin:PASSWORD@db-postgresql-nyc3-XXXX.db.ondigitalocean.com:25061/defaultdb?sslmode=require"

# Connect direct to PostgreSQL (port 25060) - for prepared statements
psql "postgresql://doadmin:PASSWORD@db-postgresql-nyc3-XXXX.db.ondigitalocean.com:25060/defaultdb?sslmode=require"

# Check active connections on both ports
SELECT count(*), state FROM pg_stat_activity GROUP BY state;
// advertisement

Failover Behavior on HA Clusters

On three-node clusters, DigitalOcean uses streaming replication with synchronous commit on the standby. Failover is automatic and promoted within 30-60 seconds in our testing. The connection string provided by DigitalOcean - a single hostname that resolves to the primary - updates its DNS record after promotion. TTL is 60 seconds.

This means you need your application connection pool to handle a 60-90 second disruption gracefully. We tested pgBouncer + HAProxy in front of the managed cluster and found it unnecessary - the built-in PgBouncer queues connections during the failover window rather than dropping them, assuming your client timeout is set above 90 seconds.

We triggered failover manually using the API to verify behavior. The cluster went unavailable for 47 seconds in our AMS3 test before the new primary accepted writes.

One caveat: the failover hostname is the same for read-write and read-only connections on basic plans. The private hostname `private-db-postgresql-nyc3-XXXX.db.ondigitalocean.com` is only available within the same VPC region. Always use the private hostname for app servers on the same DigitalOcean VPC - it avoids bandwidth charges and cuts latency by 1-3ms in our measurements.

# Trigger manual failover via API (useful for testing)
curl -X PUT \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID/migrate" \
  -d '{"region": "ams3"}'

# Check cluster status after failover
curl -s \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID" | jq '.database.status'

Backups and Point-in-Time Recovery

Automated backups run daily and are retained for 7 days on all plans. Point-in-time recovery (PITR) is available on PostgreSQL clusters and lets you restore to any second within the retention window. This uses WAL archiving, and in our tests the restore of a 40GB database took approximately 22 minutes to a new cluster.

Restoration always creates a new cluster - you cannot restore in-place. This is a meaningful operational constraint. Your restore process requires updating connection strings in your application, which takes time unless you have a service discovery layer in front. We use a Redis key or environment variable injection via a deployment pipeline to handle the switchover.

MySQL clusters on DigitalOcean get daily backups but no WAL-equivalent PITR - you get restore to a specific backup snapshot, not an arbitrary timestamp. If you need sub-daily recovery points for MySQL, you need to run your own binary log shipping alongside the managed service.

To take an on-demand backup before a risky schema migration, use the API. The control panel does not surface this option.

# Create an on-demand backup before a schema migration
curl -X POST \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID/backups"

# List available backups
curl -s \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID/backups" | jq '.backups[] | {created_at, size_gigabytes}'

Migrating an Existing PostgreSQL Cluster

DigitalOcean provides a migration service at `https://api.digitalocean.com/v2/databases/$CLUSTER_ID/online-migration` that uses logical replication to replicate from a source PostgreSQL instance to the managed cluster with minimal downtime. We used this to migrate a 120GB PostgreSQL 14 cluster from a self-managed Droplet to a managed cluster.

Prerequisites: the source must have `wal_level = logical`, `max_replication_slots >= 1`, and `max_wal_senders >= 1`. The source also needs to be reachable from DigitalOcean's migration service IPs, which means temporarily allowing inbound on port 5432 from their published CIDR ranges or allowlisting the migration service.

The migration ran the initial copy in 4 hours for our 120GB dataset, then switched to logical replication for the remaining delta. We saw about 200ms replication lag at steady state during business hours. We cut over by stopping writes on the source, waiting for lag to hit zero, then updating connection strings. Total downtime: 18 seconds.

Sequence ownership is a known issue. After migration, sequences owned by tables may not transfer correctly. Run `\ds+` on both sides and compare before cutover.

When you are naming the new cluster or associated resources in a project - especially if it maps to a customer-facing subdomain - keeping the naming consistent with your domain structure matters. We use a project naming convention that aligns with how we register domains at nicename.me, which makes it straightforward to map `db.project-name.com` to the right private endpoint without confusion across environments.

# On source PostgreSQL, verify logical replication is enabled
psql -U postgres -c "SHOW wal_level;"
psql -U postgres -c "SHOW max_replication_slots;"

# Set if not already configured (requires restart)
# In postgresql.conf:
# wal_level = logical
# max_replication_slots = 5
# max_wal_senders = 5

# Start migration via API
curl -X PUT \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID/online-migration" \
  -d '{
    "source": {
      "host": "SOURCE_HOST",
      "port": 5432,
      "dbname": "mydb",
      "username": "postgres",
      "password": "PASSWORD"
    },
    "disable_ssl": false
  }'
// advertisement

Performance Tuning Within Managed Constraints

You cannot edit `postgresql.conf` directly. DigitalOcean exposes a subset of tunable parameters through the API under `/v2/databases/$CLUSTER_ID/config`. As of 2026, these include `work_mem`, `shared_buffers_percentage`, `max_connections`, `pg_partman_bgw.interval`, `autovacuum_max_workers`, and a handful of others. The full list is in their API docs and changes with major version updates.

On our `db-s-4vcpu-8gb` nodes (8GB RAM), the default `shared_buffers` sits at 25% of RAM (2GB). We increased `work_mem` from the default 4MB to 32MB for our analytics workload, which dropped query time on a specific GROUP BY query from 8.4 seconds to 1.1 seconds on a 200M row table.

For DevOps teams that want to automate config tuning across multiple clusters - especially when adjusting parameters after resizing - tools like taskbotshub.ai can help generate and apply parameter recommendations via the DigitalOcean API based on workload metrics, reducing the manual loop of benchmark, adjust, repeat.

`pg_stat_statements` is enabled by default on DigitalOcean managed PostgreSQL, which makes performance analysis straightforward. Query the view to find your top time consumers immediately after enabling slow query logging.

# Update work_mem via API
curl -X PATCH \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID/config" \
  -d '{"work_mem": 32768}'

# Find top 10 queries by total execution time
psql $DATABASE_URL -c "
SELECT 
  round(total_exec_time::numeric, 2) AS total_ms,
  calls,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  left(query, 80) AS query_snippet
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
"

Read Replicas and Horizontal Scaling

Read replicas are available for PostgreSQL and MySQL. They are provisioned as separate clusters with their own connection strings. Replication is asynchronous - DigitalOcean does not expose replication lag metrics in the control panel, but you can query it directly.

Replica clusters are billed at the same rate as primary clusters. A read replica at the `db-s-2vcpu-4gb` level adds $50/month. There is no read replica tier pricing.

You cannot promote a read replica to a standalone cluster without going through support or destroying and recreating. This limits their use for blue-green database deployments. For that pattern, we found it cleaner to provision an independent cluster and use logical replication manually.

Horizontal scaling for write workloads is not something DigitalOcean Managed Databases solves. Kafka support is available for event streaming use cases, but for distributed SQL you are looking at managing something like CockroachDB or Vitess yourself on Droplets. DigitalOcean does not offer a distributed SQL managed service as of mid-2026.

We provision and manage DigitalOcean infrastructure including managed databases via their platform at https://digitalocean.com/ - new accounts include credits that cover several months of a single-node cluster for evaluation.

# Check replication lag on a read replica (run on the replica)
SELECT 
  now() - pg_last_xact_replay_timestamp() AS replication_lag,
  pg_is_in_recovery() AS is_replica;

# Create a read replica via API
curl -X POST \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID/replicas" \
  -d '{
    "name": "pg-prod-replica-1",
    "size": "db-s-2vcpu-4gb",
    "region": "ams3"
  }'

Pricing Reality Check

The entry-level PostgreSQL cluster is $15/month for `db-s-1vcpu-1gb` in most regions. That node runs with 25 max connections and 256MB shared_buffers - it is adequate only for development. For production, the minimum realistic size is `db-s-2vcpu-4gb` at $50/month.

HA (three-node) triples the compute cost and adds a $50/month charge for the managed standby, putting a production-ready HA PostgreSQL cluster at $200/month minimum before backups (included) or read replicas (extra).

Compare that against running your own PostgreSQL on three Droplets: three `s-2vcpu-4gb` Droplets at $24/month each is $72/month, plus your engineering time for replication setup, failover testing, backup management, and OS maintenance. The managed service breaks even on engineering time if your team spends more than 3-4 hours per month on database operations.

Bandwidth within DigitalOcean's private network is free. External egress is $0.01/GB after the free tier. Keep your app servers and database clusters in the same region and use private hostnames to avoid any egress billing on database traffic.

# List available database sizes and prices via API
curl -s \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  "https://api.digitalocean.com/v2/databases/options" | jq '.options.pg.sizes[] | {slug: .slug, vcpus: .vcpus, memory_mib: .memory_mib, disk_gib: .disk_gib, price_monthly: .price_monthly}'
// advertisement

Monitoring and Alerting

DigitalOcean exposes cluster metrics through their Insights dashboard, including CPU, memory, disk, and connection count. These are retained for 30 days. You cannot export these metrics directly to Prometheus without using the DigitalOcean exporter or polling the API.

For teams running their own Prometheus stack, the `digitalocean-exporter` project (github.com/metalmatze/digitalocean-exporter) polls the API and exposes metrics on port 9212. We run it as a sidecar and scrape it with a standard Prometheus job. This gives us alerting on connection saturation and disk usage alongside our application metrics.

DigitalOcean's native alerting through the control panel supports email and Slack notifications for CPU, memory, disk, and connection count thresholds. This is sufficient for basic alerting but cannot do composite conditions or derived metrics. For anything beyond threshold alerting, external Prometheus plus Alertmanager is the practical path.

Slowquery logging can be enabled via the config API. Set `log_min_duration_statement` to a value in milliseconds. We use 500ms on production, which captures queries needing attention without flooding logs.

# Enable slow query logging (queries > 500ms)
curl -X PATCH \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID/config" \
  -d '{"log_min_duration_statement": 500}'

# View logs via CLI (doctl)
doctl databases logs $CLUSTER_ID --follow

# Or via API
curl -s \
  -H "Authorization: Bearer $DO_API_TOKEN" \
  "https://api.digitalocean.com/v2/databases/$CLUSTER_ID/logs" | jq '.logs[]'