DigitalOcean's Infrastructure-as-Code Workflow: From Terraform to Production
You’ve spun up DigitalOcean Droplets manually through the dashboard, but now your team needs to provision 50 identical environments across development, staging, and production. Time to stop clicking and start coding your infrastructure.
The manual approach breaks down fast. Each Droplet takes three minutes to configure through the web UI—firewall rules, SSH keys, tags, monitoring agents. Multiply that by 50 environments, and you’re burning half a workday on repetitive clicks. Worse, environment drift creeps in: the staging database gets 4GB of RAM while production has 8GB because someone fat-fingered a dropdown last Tuesday. Your deployment runbook becomes a 47-step checklist that nobody follows exactly.
DigitalOcean’s API changes this equation. Every resource you can click in the dashboard—Droplets, Load Balancers, Managed Databases, Spaces—exposes a REST endpoint. More importantly, the API surface stays consistent: whether you’re provisioning a $6/month Droplet or a $240/month managed Postgres cluster, you’re working with the same authentication patterns, the same error handling, the same idempotent resource updates.
This API-first design makes DigitalOcean particularly suited for infrastructure-as-code workflows. No special case logic for “this resource type uses a different SDK.” No surprise billing when an API call spawns three hidden support resources. The provider’s simplicity maps directly to simpler Terraform modules: a Droplet resource declaration runs 15 lines, not 150.
That simplicity carries financial weight. When your IaC pipeline spins up ephemeral test environments for two hours of integration testing, per-second billing means you pay for 7,200 seconds of compute—not a full month because you forgot to destroy resources. This precision turns infrastructure code from a cost center into a cost optimization tool.
Why DigitalOcean’s API-First Architecture Matters for Modern Teams
Platform engineers inheriting legacy Heroku deployments face a familiar dilemma: accept limited control and rising costs, or migrate to AWS and inherit operational complexity that requires dedicated infrastructure teams. DigitalOcean’s API-first design provides a third path—predictable infrastructure costs with enterprise-grade automation capabilities that match how modern teams actually work.

Cost Predictability Without Vendor Lock-In
DigitalOcean’s pricing model eliminates the spreadsheet gymnastics required to estimate AWS bills. A 4GB Droplet costs $24/month whether it serves 100 requests or 100,000. Managed Kubernetes clusters run $12/month for the control plane with transparent worker node pricing. Spaces object storage charges $5/month for 250GB with no egress fees between DigitalOcean resources—eliminating the data transfer costs that make AWS calculators unusable for real-world planning.
Per-second billing for ephemeral compute removes cost barriers to CI/CD experimentation. Spinning up test environments for pull request validation, running daily integration tests against fresh database snapshots, or provisioning temporary staging clusters for load testing incurs costs measured in cents, not planning meetings. Teams adopt infrastructure-as-code patterns faster when destroying and recreating resources doesn’t trigger budget alerts.
API Consistency Across the Stack
The DigitalOcean API maintains semantic consistency across compute, storage, and networking primitives. Creating a Droplet, provisioning a managed PostgreSQL cluster, and attaching a load balancer use identical authentication patterns and response structures. This uniformity reduces cognitive overhead when writing Terraform modules—patterns learned for Droplet management transfer directly to DNS record automation or firewall rule deployment.
The API’s resource tagging system enables organizational strategies that scale beyond toy projects. Tag Droplets with environment:production and team:platform to generate cost allocation reports without parsing resource names. Apply firewall rules to all resources tagged role:api-gateway instead of maintaining IP address lists. Delete entire staging environments by targeting tags in Terraform destroy operations.
Migration Path from Platform-as-a-Service Dependencies
Teams leaving Heroku’s dyno model find direct equivalents in DigitalOcean’s managed services. App Platform provides git-push deployments with automatic HTTPS, health checks, and zero-downtime rollbacks—matching Heroku’s developer experience without proprietary buildpacks. When applications outgrow platform abstractions, the underlying Droplets, Kubernetes clusters, and databases remain accessible through the same API used by managed services.
The Terraform provider exposes every API endpoint as declarative resources, transforming ClickOps experiments into production-ready infrastructure code. What starts as manual firewall configuration in the web console becomes versioned .tf files that deploy identical staging and production networks. This evolutionary approach lets teams adopt infrastructure-as-code practices incrementally rather than requiring upfront architectural rewrites.
With foundational context established, the next section demonstrates practical Terraform provider configuration and authentication patterns that secure API access for production deployments.
Terraform Provider Setup and Authentication Patterns
Before provisioning infrastructure on DigitalOcean, you need a properly configured Terraform provider with secure authentication. This foundation ensures repeatable deployments and enables team collaboration through proper state management.
Provider Configuration
The DigitalOcean Terraform provider requires minimal configuration. Create a provider.tf file in your project root:
terraform { required_providers { digitalocean = { source = "digitalocean/digitalocean" version = "~> 2.43.0" } } required_version = ">= 1.6.0"}
provider "digitalocean" { token = var.do_token}Pin the provider version to avoid breaking changes in production environments. The ~> constraint allows patch updates while preventing minor version upgrades that might introduce incompatibilities. Check the provider changelog before upgrading to understand deprecations and new features.
The provider block accepts additional optional parameters for advanced scenarios. Set spaces_access_id and spaces_secret_key when managing Spaces resources separately from your main API token, or configure api_endpoint for private networking scenarios where API requests route through internal networks.
API Token Management
Never hardcode API tokens in your Terraform files. DigitalOcean generates personal access tokens through the API section of your control panel. Create a token with read and write scopes for infrastructure management.
Define the token as a sensitive variable:
variable "do_token" { description = "DigitalOcean API token" type = string sensitive = true}Pass the token via environment variable to keep it out of version control:
export TF_VAR_do_token="dop_v1_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"terraform planFor CI/CD pipelines, store the token in your platform’s secret management system (GitHub Actions secrets, GitLab CI/CD variables, or Jenkins credentials). Reference it in your workflow without exposing the raw value in logs. Most CI platforms support masked variables that prevent accidental leakage through console output.
Token scope matters for security posture. DigitalOcean supports granular permissions, allowing you to create tokens restricted to specific operations. A deployment pipeline might use a read-only token for terraform plan operations and a separate read-write token for terraform apply steps, reducing the window of elevated privilege exposure.
💡 Pro Tip: Use separate API tokens for production and development environments. This limits blast radius if a token is compromised and makes rotation easier during security incidents.
Implement token rotation policies aligned with your organization’s security requirements. Automate rotation through scripts that update CI/CD secrets and notify team members. The DigitalOcean API supports programmatic token management, enabling infrastructure-as-code approaches to credential lifecycle management.
State Management for Teams
Terraform state files contain sensitive data including resource IDs and metadata. Local state works for individual experimentation, but teams need remote state with locking to prevent concurrent modification conflicts.
DigitalOcean Spaces provides S3-compatible object storage ideal for Terraform state. Configure remote state after creating a Space and generating access keys:
terraform { backend "s3" { endpoint = "nyc3.digitaloceanspaces.com" region = "us-east-1" bucket = "terraform-state-prod" key = "infrastructure/terraform.tfstate" skip_credentials_validation = true skip_metadata_api_check = true }}Initialize the backend with Spaces credentials:
export AWS_ACCESS_KEY_ID="DO00EXAMPLE9ABCDEFGH"export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"terraform initThe S3-compatible API means existing tooling works without modification. State locking happens automatically through DigitalOcean’s infrastructure, preventing race conditions when multiple engineers run deployments.
For enhanced security, enable encryption at rest on your Spaces bucket and restrict access using Spaces access keys with minimal required permissions. Configure lifecycle policies to retain state file versions for disaster recovery—thirty days of version history provides sufficient rollback capability for most teams. Rotate Spaces access keys quarterly and audit access logs through DigitalOcean’s monitoring dashboard.
Consider state file organization carefully. Separate state files by environment (production, staging, development) and functional domain (networking, compute, databases). This isolation reduces blast radius during state corruption events and allows different team members to manage subsystems independently. Use workspaces for environment separation within a single configuration or maintain entirely separate Terraform projects depending on your change management requirements.
With authentication and state management configured, you’re ready to define actual infrastructure resources. The next section covers provisioning Droplets with automated configuration through cloud-init and SSH key injection.
Droplet Provisioning with Cloud-Init and SSH Key Injection
DigitalOcean Droplets form the foundation of most infrastructure deployments, and Terraform’s declarative approach eliminates the manual configuration drift that plagues point-and-click provisioning. The digitalocean_droplet resource exposes the full API surface while maintaining idempotent state management—critical when managing fleets of hundreds of instances across staging and production.
Defining Droplet Resources with User Data
Cloud-init integration transforms bare Droplets into production-ready nodes without external configuration management tools. The user_data parameter accepts shell scripts or cloud-config YAML that execute on first boot, handling everything from package installation to application deployment. This eliminates the need for separate provisioners or post-deployment configuration steps that introduce timing dependencies and error-prone SSH connections.
resource "digitalocean_droplet" "web" { count = var.web_server_count name = "web-${count.index + 1}" region = "nyc3" size = "s-2vcpu-4gb" image = "ubuntu-24-04-x64"
user_data = templatefile("${path.module}/scripts/web-init.sh", { app_env = var.environment db_host = digitalocean_database_cluster.postgres.private_host redis_uri = digitalocean_database_cluster.redis.uri })
ssh_keys = [ data.digitalocean_ssh_key.terraform.id, digitalocean_ssh_key.deploy.id ]
vpc_uuid = digitalocean_vpc.main.id
tags = ["web", "terraform", var.environment]}The templatefile function enables environment-specific configuration injection without hardcoding credentials. Reference outputs from other resources like database endpoints directly—Terraform resolves dependencies and enforces correct creation order automatically. This pattern ensures database clusters exist before Droplets attempt to connect, preventing race conditions that cause deployment failures.
Cloud-init scripts execute with root privileges and have access to instance metadata through the metadata service at 169.254.169.254. Leverage this for dynamic configuration: query tags, retrieve neighboring instances, or pull secrets from DigitalOcean Spaces. The execution log persists at /var/log/cloud-init-output.log, essential for debugging bootstrap failures without SSH access.
💡 Pro Tip: Store cloud-init scripts in version control alongside your Terraform code. This creates a complete audit trail of infrastructure changes and enables code review for security-critical bootstrap logic. Consider splitting complex initialization into multiple scripts loaded via cloud-config’s
#includedirective for better modularity.
For stateful workloads, attach volumes before running user data scripts. The volume_ids parameter accepts a list of block storage IDs, and cloud-init can automatically format and mount them using the mounts directive. This separates ephemeral OS disks from persistent data, enabling Droplet recreation without data loss.
SSH Key Management Across Environments
SSH key injection happens at the hypervisor level before the Droplet boots, making it more reliable than runtime key distribution. DigitalOcean’s API supports both pre-existing keys via data sources and dynamically created keys for ephemeral environments. The platform injects keys into ~/.ssh/authorized_keys for the default user account (root for most distributions, ubuntu for Ubuntu images), and cloud-init respects this initial configuration.
data "digitalocean_ssh_key" "terraform" { name = "terraform-automation"}
resource "digitalocean_ssh_key" "deploy" { name = "deploy-${var.environment}" public_key = file("~/.ssh/deploy_${var.environment}.pub")}
resource "digitalocean_ssh_key" "ci_runner" { count = var.environment == "production" ? 1 : 0 name = "github-actions-runner" public_key = var.ci_public_key}The data source pattern prevents Terraform from attempting to recreate organization-wide keys that exist outside the current workspace’s scope. Use resource blocks for environment-specific keys that should be destroyed alongside the infrastructure. This separation enables shared administrative access while maintaining environment isolation for automated systems.
For production deployments, combine multiple SSH keys on each Droplet—one for break-glass emergency access, one for automated deployment pipelines, and one for monitoring agents. The ssh_keys parameter accepts a list of key IDs, enabling fine-grained access control without sharing private keys across teams. Rotate deployment keys quarterly by creating new resources and updating Droplet configurations, letting Terraform handle the replacement workflow.
Avoid storing private keys in Terraform state or version control. Use file() to read public keys from the filesystem, and manage private keys through dedicated secrets management systems. For CI/CD environments, inject public keys via environment variables and reference them with var.ci_public_key as shown above.
Image Selection and Sizing Strategies
DigitalOcean maintains first-party images for major distributions with predictable slugs: ubuntu-24-04-x64, debian-12-x64, rockylinux-9-x64. These receive security patches through standard OS update mechanisms rather than requiring image rebuilds. For repeatable deployments, snapshot custom images after configuration and reference them by ID using the digitalocean_droplet_snapshot data source. Custom images dramatically reduce boot times and eliminate user data complexity for immutable infrastructure patterns.
variable "droplet_sizes" { type = map(string) default = { web = "s-2vcpu-4gb" worker = "s-4vcpu-8gb" cache = "s-1vcpu-2gb" }}
variable "regions" { type = map(object({ primary = string failover = string })) default = { production = { primary = "nyc3" failover = "sfo3" } staging = { primary = "tor1" failover = "tor1" } }}Right-sizing Droplets prevents cost overruns—the s-series family offers predictable monthly pricing without egress charges or hidden fees. Start with s-2vcpu-4gb for web applications and scale vertically by updating the size parameter. Terraform handles the resize operation with a single apply cycle, though this triggers a reboot. For zero-downtime scaling, create new Droplets with larger sizes and update load balancer targets before destroying the old instances.
Region selection impacts latency and regulatory compliance. Use the regions map structure shown above to define primary and failover locations per environment, enabling disaster recovery configurations without duplicating resource definitions. The digitalocean_regions data source provides available regions, but hardcoding stable values prevents deployment failures when DigitalOcean temporarily disables capacity in specific datacenters.
Monitor Droplet metrics through DigitalOcean’s built-in graphs or export to external systems via the metrics agent. The monitoring parameter (defaults to false) enables enhanced metrics collection without additional cost, tracking CPU, memory, disk I/O, and network throughput at one-minute granularity. Enable this for production workloads to detect resource constraints before they impact availability.
With Droplets defined and accessible via SSH, the next step is deciding between DigitalOcean’s managed Kubernetes service and self-managed cluster deployments—a choice that fundamentally shapes operational complexity.
Kubernetes Deployment on DOKS vs Self-Managed Clusters
DigitalOcean Kubernetes (DOKS) offers a fully managed control plane with automatic upgrades and scaling, while self-managed clusters on Droplets provide complete customization at the cost of operational overhead. The decision hinges on team size, workload characteristics, and tolerance for infrastructure maintenance.
When DOKS Makes Sense
DOKS eliminates control plane management entirely. DigitalOcean handles etcd backups, API server availability, and Kubernetes version upgrades. For teams under ten engineers or those prioritizing feature velocity over infrastructure customization, DOKS reduces operational burden significantly.
The pricing model charges $12/month per cluster plus node costs. This flat fee becomes negligible for production workloads but impacts economics for ephemeral environments. A three-node cluster with 4GB/2vCPU worker nodes costs approximately $72/month ($12 control plane + $24/node × 3).
resource "digitalocean_kubernetes_cluster" "production" { name = "production-cluster" region = "nyc3" version = "1.29.1-do.0"
node_pool { name = "worker-pool" size = "s-2vcpu-4gb" auto_scale = true min_nodes = 3 max_nodes = 10 tags = ["production", "web"] }
maintenance_policy { day = "sunday" start_time = "04:00" }}
output "kubeconfig" { value = digitalocean_kubernetes_cluster.production.kube_config[0].raw_config sensitive = true}DOKS integrates natively with DigitalOcean Load Balancers and Volumes. Creating a LoadBalancer service automatically provisions a load balancer without manual Terraform resources. Similarly, PersistentVolumeClaims provision Block Storage volumes directly through the CSI driver.
The managed service includes built-in monitoring through the DigitalOcean control panel, exposing cluster health metrics, resource utilization, and upgrade status without requiring Prometheus or Grafana deployments. Control plane logs stream to DigitalOcean’s logging infrastructure automatically, reducing observability overhead during incident response.
High availability comes standard with DOKS. The control plane runs across three zones within a region, eliminating single points of failure for the API server, scheduler, and controller manager. Self-managed clusters require manual multi-master configuration and load balancer setup to achieve equivalent reliability.
Self-Managed Clusters for Control
Self-managed clusters on Droplets eliminate the $12 cluster fee and provide kernel-level customization. Teams running specialized workloads—real-time processing, custom network plugins, or air-gapped deployments—often require this flexibility.
Bootstrapping requires kubeadm or kubespray. The following provisions a three-node cluster with kubeadm initialization via cloud-init:
resource "digitalocean_droplet" "k8s_control_plane" { name = "k8s-control-01" size = "s-2vcpu-4gb" image = "ubuntu-22-04-x64" region = "nyc3"
user_data = <<-EOF #!/bin/bash curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.29/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.29/deb/ /' | tee /etc/apt/sources.list.d/kubernetes.list apt-get update && apt-get install -y kubelet kubeadm kubectl containerd kubeadm init --pod-network-cidr=10.244.0.0/16 EOF
ssh_keys = [data.digitalocean_ssh_key.terraform.id]}
resource "digitalocean_droplet" "k8s_workers" { count = 2 name = "k8s-worker-${count.index + 1}" size = "s-2vcpu-4gb" image = "ubuntu-22-04-x64" region = "nyc3"
ssh_keys = [data.digitalocean_ssh_key.terraform.id]}This approach saves $12/month but adds operational complexity: manual certificate rotation, etcd backup automation, and control plane upgrades. Budget six to eight hours monthly for cluster maintenance per environment.
Self-managed deployments excel when compliance requirements mandate specific kernel versions, custom SELinux policies, or audit configurations unavailable in managed offerings. Financial services and healthcare workloads frequently fall into this category, where attestation requirements exceed what shared control planes provide.
CNI plugin selection becomes critical for self-managed clusters. While DOKS defaults to Cilium, self-managed deployments can implement Calico for network policy enforcement, Flannel for simplicity, or Weave for encryption. This flexibility supports specialized networking requirements like service mesh integration or multi-cloud connectivity.
💡 Pro Tip: Use DOKS for production and staging, then provision ephemeral test clusters with k3s on single Droplets. This hybrid approach balances reliability with cost efficiency for transient environments.
Cost Breakeven Analysis
DOKS becomes cost-effective above 40 cluster-hours monthly. For organizations running five staging environments for eight hours daily, self-managed clusters cost less ($120 in compute vs $180 for DOKS with control plane fees). Production clusters running 24/7 favor DOKS due to included high availability and monitoring.
The total cost of ownership extends beyond infrastructure fees. Factor in personnel costs for cluster maintenance—if platform engineers spend eight hours monthly managing self-hosted clusters at a $150/hour fully-loaded rate, the $1,200 labor cost dwarfs the $12 DOKS fee. This calculation reverses only for organizations with dozens of clusters where dedicated platform teams amortize maintenance across many environments.
With cluster infrastructure defined, the next layer addresses network isolation through VPCs and firewall rules that segment environments and restrict traffic flow.
Networking: VPCs, Firewalls, and Load Balancers
DigitalOcean’s networking primitives—Virtual Private Clouds (VPCs), Cloud Firewalls, and Load Balancers—form the foundation of secure, scalable infrastructure. Unlike AWS’s sprawling VPC model with subnets, route tables, and NATs, DigitalOcean simplifies network isolation while maintaining production-grade security.

VPC Architecture for Multi-Tier Applications
VPCs create private networks where resources communicate via internal IP addresses, isolating application tiers from public internet exposure. By default, all resources within a region share a legacy network, but explicit VPC design prevents unauthorized cross-service access.
resource "digitalocean_vpc" "production" { name = "prod-vpc-nyc3" region = "nyc3" ip_range = "10.10.0.0/16"
description = "Production environment VPC"}
resource "digitalocean_droplet" "web" { count = 3 image = "ubuntu-24-04-x64" name = "web-${count.index + 1}" region = "nyc3" size = "s-2vcpu-4gb" vpc_uuid = digitalocean_vpc.production.id
tags = ["web", "production"]}
resource "digitalocean_droplet" "database" { image = "ubuntu-24-04-x64" name = "db-primary" region = "nyc3" size = "s-4vcpu-8gb" vpc_uuid = digitalocean_vpc.production.id
tags = ["database", "production"]}This configuration ensures web servers and databases communicate over the 10.10.0.0/16 private network. External traffic reaches web servers through their public IPs, while database instances remain entirely private—a critical security boundary for protecting sensitive data.
The flat VPC model eliminates subnet complexity but requires careful planning. Unlike AWS where you segment VPCs into public and private subnets with NAT gateways, DigitalOcean VPCs function as a single layer-2 broadcast domain. All resources within a VPC can theoretically communicate unless restricted by firewalls, making defense-in-depth through Cloud Firewalls essential rather than optional.
💡 Pro Tip: Use
/16CIDR blocks for VPCs to accommodate future growth. DigitalOcean doesn’t support subnet segmentation within VPCs, so plan your IP allocation carefully. Reserve/16ranges that won’t conflict with on-premises networks if you’re building hybrid infrastructure.
Firewall Rules as Code
Cloud Firewalls attach to Droplets via tags, enabling declarative security policies that scale automatically as infrastructure grows. This tag-based approach eliminates the manual firewall-to-instance mapping required in other platforms—when you launch a Droplet with tags = ["web"], it automatically inherits the web-tier-firewall rules.
resource "digitalocean_firewall" "web" { name = "web-tier-firewall"
tags = ["web"]
inbound_rule { protocol = "tcp" port_range = "80" source_addresses = ["0.0.0.0/0", "::/0"] }
inbound_rule { protocol = "tcp" port_range = "443" source_addresses = ["0.0.0.0/0", "::/0"] }
inbound_rule { protocol = "tcp" port_range = "22" source_addresses = ["203.0.113.0/24"] }
outbound_rule { protocol = "tcp" port_range = "5432" destination_tags = ["database"] }
outbound_rule { protocol = "tcp" port_range = "1-65535" destination_addresses = ["0.0.0.0/0", "::/0"] }}
resource "digitalocean_firewall" "database" { name = "database-firewall"
tags = ["database"]
inbound_rule { protocol = "tcp" port_range = "5432" source_tags = ["web"] }
outbound_rule { protocol = "tcp" port_range = "443" destination_addresses = ["0.0.0.0/0", "::/0"] }}This dual-firewall approach implements the principle of least privilege: databases accept connections only from web-tier Droplets via source_tags = ["web"], while web servers permit public HTTP/HTTPS but restrict SSH to a corporate IP range. The database outbound rule allows HTTPS for package updates and certificate validation, but blocks all other external communication.
Firewall rules are stateful—return traffic for established connections is automatically permitted. DigitalOcean applies these rules at the hypervisor level before packets reach your Droplet, providing protection even if the guest OS firewall is misconfigured.
Load Balancers with Health Checks
DigitalOcean’s managed Load Balancers distribute traffic across Droplets with automatic health monitoring, SSL termination, and support for sticky sessions. They integrate with Cloud Firewalls and VPCs to form a complete edge ingress layer.
resource "digitalocean_loadbalancer" "web" { name = "web-lb-nyc3" region = "nyc3" vpc_uuid = digitalocean_vpc.production.id
forwarding_rule { entry_protocol = "https" entry_port = 443 target_protocol = "http" target_port = 80 certificate_name = "le-cert-timderzhavets-com" }
forwarding_rule { entry_protocol = "http" entry_port = 80 target_protocol = "http" target_port = 80 }
healthcheck { protocol = "http" port = 80 path = "/health" check_interval_seconds = 10 response_timeout_seconds = 5 healthy_threshold = 3 unhealthy_threshold = 2 }
droplet_tag = "web"
sticky_sessions { type = "cookies" cookie_name = "lb_session" cookie_ttl_seconds = 3600 }}Health checks probe /health every 10 seconds, marking backends unhealthy after two consecutive failures and requiring three successes to restore them. The certificate_name references a Let’s Encrypt certificate managed through DigitalOcean’s Certificate API, enabling SSL termination at the edge—this offloads decryption from application servers and simplifies certificate management.
Sticky sessions via cookies ensure user requests route to the same backend, critical for applications storing session state locally. For stateless applications, omit this block to enable pure round-robin distribution with better failover characteristics.
The load balancer automatically discovers backends through droplet_tag = "web", matching the same tagging strategy used for firewalls. When you scale web tier capacity by launching additional Droplets with the web tag, they join the load balancer pool without manual configuration changes.
With VPCs isolating environments, firewalls enforcing zero-trust network policies, and load balancers ensuring high availability, your network topology supports production workloads. The next layer—persistent storage and managed databases—completes the infrastructure foundation.
Spaces, Managed Databases, and Persistent Storage
DigitalOcean Spaces provides S3-compatible object storage that integrates directly into your Terraform workflow. Combined with managed databases for PostgreSQL, MySQL, Redis, and MongoDB, you can provision production-grade data infrastructure without managing underlying instances. This declarative approach to storage and databases ensures consistency across environments while leveraging DigitalOcean’s automated maintenance, backup, and scaling capabilities.
Object Storage with Spaces
Spaces uses the S3 API, meaning existing tools like aws-cli, s3cmd, and application SDKs work without modification. Create a Space and configure CORS policies directly in Terraform:
resource "digitalocean_spaces_bucket" "assets" { name = "prod-static-assets" region = "nyc3"
cors_rule { allowed_headers = ["*"] allowed_methods = ["GET", "HEAD"] allowed_origins = ["https://app.example.com"] max_age_seconds = 3600 }}
resource "digitalocean_spaces_bucket_object" "deployment_config" { bucket = digitalocean_spaces_bucket.assets.name region = digitalocean_spaces_bucket.assets.region key = "config/production.json" content = file("${path.module}/configs/production.json") content_type = "application/json" acl = "private"}Each Space supports up to 500TB of storage with no egress fees for DigitalOcean-to-DigitalOcean transfers, making it cost-effective for serving assets to applications running on Droplets or Kubernetes clusters. Access keys use a separate resource to avoid coupling lifecycle management:
resource "digitalocean_spaces_bucket_policy" "assets_policy" { bucket = digitalocean_spaces_bucket.assets.id region = digitalocean_spaces_bucket.assets.region
policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = "*" Action = "s3:GetObject" Resource = "${digitalocean_spaces_bucket.assets.bucket_domain_name}/public/*" }] })}For CDN-backed delivery, enable the built-in CDN endpoint on your Space to distribute content globally with automatic SSL certificate provisioning. This reduces latency for users worldwide without additional infrastructure configuration.
Managed Database Clusters
DigitalOcean manages database maintenance windows, automated backups, and failover. Provision a PostgreSQL cluster with read replicas:
resource "digitalocean_database_cluster" "postgres" { name = "prod-pg-cluster" engine = "pg" version = "16" size = "db-s-2vcpu-4gb" region = "nyc3" node_count = 2
maintenance_window { day = "sunday" hour = "03:00:00" }}
resource "digitalocean_database_db" "application" { cluster_id = digitalocean_database_cluster.postgres.id name = "application_production"}
resource "digitalocean_database_user" "app_user" { cluster_id = digitalocean_database_cluster.postgres.id name = "app_service"}
resource "digitalocean_database_firewall" "postgres_fw" { cluster_id = digitalocean_database_cluster.postgres.id
rule { type = "k8s" value = digitalocean_kubernetes_cluster.production.id }
rule { type = "ip_addr" value = "203.0.113.45" # CI/CD runner }}Database firewall rules enforce network-level access control, allowing connections only from trusted sources like your Kubernetes cluster or CI/CD infrastructure. The k8s rule type automatically updates as cluster nodes scale, eliminating manual IP management.
Connection strings are available as output attributes. Inject them into Kubernetes secrets or application configuration:
output "postgres_connection" { value = digitalocean_database_cluster.postgres.uri sensitive = true}
output "postgres_host" { value = digitalocean_database_cluster.postgres.host}
output "postgres_port" { value = digitalocean_database_cluster.postgres.port}Database connection pooling is built-in via PgBouncer for PostgreSQL clusters, accessible through a separate connection pool URI that handles up to 5,000 concurrent connections per node. This eliminates the need to deploy and manage dedicated pooling infrastructure while protecting your database from connection exhaustion.
Backup Automation and Disaster Recovery
Managed databases include automated daily backups with point-in-time recovery extending back seven days for basic plans and up to thirty days for production clusters. Backups run during maintenance windows to minimize performance impact, with automatic retention management that rotates older backups as new ones complete.
For critical workloads requiring cross-region redundancy, create read replicas in geographically distant datacenters. These replicas maintain near-real-time synchronization with the primary cluster and can be promoted to standalone clusters during regional outages:
resource "digitalocean_database_replica" "postgres_replica" { cluster_id = digitalocean_database_cluster.postgres.id name = "prod-pg-replica-sfo" region = "sfo3" size = "db-s-2vcpu-4gb"}Redis clusters support read replicas for high availability and can be configured with eviction policies that match your caching strategy:
resource "digitalocean_database_cluster" "redis" { name = "prod-cache" engine = "redis" version = "7" size = "db-s-1vcpu-2gb" region = "nyc3" node_count = 1
eviction_policy = "allkeys_lru"}For Spaces, implement versioning to protect against accidental deletions and enable point-in-time recovery of objects. While DigitalOcean doesn’t provide built-in lifecycle policies for Spaces, you can use S3-compatible backup tools like rclone or restic to create scheduled backups to secondary storage providers, ensuring redundancy across cloud platforms.
💡 Pro Tip: Use
digitalocean_database_replicaresources to create geographically distributed read replicas for multi-region deployments. Replicas automatically stay synchronized with the primary cluster and distribute read traffic to reduce latency for global user bases.
With storage and databases codified, the next layer integrates continuous deployment pipelines that automatically apply infrastructure changes when you merge to your main branch.
CI/CD Integration and GitOps Workflows
Automating Terraform deployments through CI/CD pipelines transforms infrastructure management from manual toil into a reviewable, auditable process. DigitalOcean’s API responds quickly enough that Terraform plan/apply cycles complete in minutes, making automated workflows practical even for large infrastructures.
GitHub Actions Terraform Workflow
A production-ready GitHub Actions workflow separates planning from applying, ensuring changes get reviewed before execution:
name: Terraform
on: pull_request: paths: - 'terraform/**' push: branches: - main
env: TF_VERSION: '1.7.0' DO_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} TF_VAR_do_token: ${{ secrets.DIGITALOCEAN_TOKEN }}
jobs: plan: runs-on: ubuntu-latest defaults: run: working-directory: ./terraform
steps: - uses: actions/checkout@v4
- name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init run: terraform init
- name: Terraform Format Check run: terraform fmt -check -recursive
- name: Terraform Validate run: terraform validate
- name: Terraform Plan id: plan run: | terraform plan -no-color -out=tfplan terraform show -no-color tfplan > plan_output.txt
- name: Comment PR if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | const fs = require('fs'); const plan = fs.readFileSync('terraform/plan_output.txt', 'utf8'); const output = `#### Terraform Plan 📖 <details><summary>Show Plan</summary>
\`\`\`terraform ${plan} \`\`\`
</details>`;
github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output });
apply: needs: plan if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest defaults: run: working-directory: ./terraform
steps: - uses: actions/checkout@v4
- name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init run: terraform init
- name: Terraform Apply run: terraform apply -auto-approveThis workflow runs terraform plan on every pull request, posting results as PR comments for team review. Merging to main triggers terraform apply, executing approved changes. The separation between plan and apply jobs creates a clear boundary between proposal and execution, with the plan job running on all PRs while apply only executes on main branch pushes.
Storing the DigitalOcean API token in GitHub Secrets keeps credentials secure while enabling automated authentication. The TF_VAR_ prefix automatically maps the secret to Terraform’s do_token variable, eliminating the need for explicit variable files in CI environments.
Advanced Plan and Apply Strategies
For high-stakes production environments, enhance the basic workflow with additional safeguards. Implement GitHub Environments with required reviewers to enforce manual approval gates:
apply: needs: plan if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: name: production url: https://cloud.digitalocean.com/projects defaults: run: working-directory: ./terraformConfigure the production environment in GitHub repository settings to require approval from designated platform team members. This creates an audit trail showing who authorized each infrastructure change.
Consider implementing speculative plans that run against feature branches targeting staging environments. This allows teams to validate infrastructure changes in isolation before merging:
on: pull_request: types: [opened, synchronize] workflow_dispatch: inputs: environment: description: 'Target environment' required: true type: choice options: - staging - productionThe workflow_dispatch trigger enables manual execution with environment selection, useful for testing changes against production configuration without merging code.
💡 Pro Tip: Store Terraform state in DigitalOcean Spaces with state locking enabled via DynamoDB-compatible metadata. This prevents concurrent modifications and provides versioned backups of your infrastructure state history.
Monitoring Deployments with DigitalOcean’s API
Post-deployment verification ensures infrastructure reached the desired state. Query the DigitalOcean API directly to confirm resource creation and operational readiness:
- name: Verify Droplet Status run: | DROPLET_ID=$(terraform output -raw web_droplet_id) STATUS=$(curl -s -X GET \ -H "Authorization: Bearer ${{ secrets.DIGITALOCEAN_TOKEN }}" \ "https://api.digitalocean.com/v2/droplets/${DROPLET_ID}" | \ jq -r '.droplet.status')
if [ "$STATUS" != "active" ]; then echo "Droplet not active, current status: $STATUS" exit 1 fi
echo "Droplet ${DROPLET_ID} is active"
- name: Verify Load Balancer Health run: | LB_ID=$(terraform output -raw load_balancer_id) HEALTH=$(curl -s -X GET \ -H "Authorization: Bearer ${{ secrets.DIGITALOCEAN_TOKEN }}" \ "https://api.digitalocean.com/v2/load_balancers/${LB_ID}" | \ jq -r '.load_balancer.status')
# Load balancers take time to provision MAX_ATTEMPTS=10 ATTEMPT=1
while [ "$HEALTH" != "active" ] && [ $ATTEMPT -le $MAX_ATTEMPTS ]; do echo "Attempt $ATTEMPT: Load balancer status is $HEALTH, waiting..." sleep 30 HEALTH=$(curl -s -X GET \ -H "Authorization: Bearer ${{ secrets.DIGITALOCEAN_TOKEN }}" \ "https://api.digitalocean.com/v2/load_balancers/${LB_ID}" | \ jq -r '.load_balancer.status') ATTEMPT=$((ATTEMPT + 1)) done
if [ "$HEALTH" != "active" ]; then echo "Load balancer failed to become active" exit 1 fiThis verification catches provisioning failures that Terraform might report as successful, particularly with asynchronous resources like load balancers and managed databases that take minutes to become fully operational. The polling logic accounts for DigitalOcean’s eventual consistency model, where resources transition through intermediate states before reaching their target configuration.
Integrating health checks directly into deployment workflows creates fast feedback loops. Failed verifications trigger rollback procedures or alert on-call engineers before traffic reaches unhealthy infrastructure. This defensive approach to automation prevents the CI/CD pipeline from becoming a liability that deploys broken configurations faster than manual processes ever could.
The next section explores monitoring and cost optimization strategies that keep production infrastructure running efficiently.
Key Takeaways
- Start with Terraform modules for Droplets and VPCs before moving to Kubernetes to avoid over-engineering
- Use DigitalOcean’s per-second billing for CI environments—destroy them after each pipeline run to cut costs by 80%
- Implement GitOps workflows with automated terraform plan comments on pull requests for team visibility