├── .gitignore ├── .terraform-version ├── .terraform.lock.hcl ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── backend.tf ├── main.tf ├── modules ├── db │ ├── main.tf │ ├── outputs.tf │ └── variables.tf ├── dbproxy │ ├── main.tf │ ├── outputs.tf │ ├── run_cloud_sql_proxy.tpl │ └── variables.tf ├── serviceaccount │ ├── main.tf │ ├── outputs.tf │ └── variables.tf └── vpc │ ├── main.tf │ ├── outputs.tf │ └── variables.tf ├── outputs.tf ├── providers.tf ├── variables.tf └── versions.tf /.gitignore: -------------------------------------------------------------------------------- 1 | .terraform/ 2 | *_override.tf 3 | *_override.tf.json 4 | *.tfstate 5 | *.tfstate.* 6 | *.tfvars 7 | crash.log 8 | override.tf 9 | override.tf.json 10 | plan 11 | -------------------------------------------------------------------------------- /.terraform-version: -------------------------------------------------------------------------------- 1 | 1.1.5 2 | -------------------------------------------------------------------------------- /.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/google" { 5 | version = "4.9.0" 6 | constraints = ">= 3.70.0" 7 | hashes = [ 8 | "h1:jXDs/S5zmRBl+dNDWIndVy/47ReLDEqOAHPbXOlhEMo=", 9 | "zh:10887917815293d6ad26cc3784c766de4dfa2fa6b2c8b994de4f4b7b3bc31653", 10 | "zh:343037e5ec514ac02bfacc200acb648861b04ee9024bed5bf72d13583d10783d", 11 | "zh:40eaf3e06e44e2278ba64fc161e96b1bd05508f599ce12e1f094a924839d34a6", 12 | "zh:5178f1043fa24a38602833aed72db5023f4183833e1fdb78bb1584a05ff53030", 13 | "zh:616889b78ee00ee69d749f7848d63246de200f17efeebfaaf28dbe3f49ec6362", 14 | "zh:695f6de8659d17f65a3317b9810cdc1c12738648b1b87dcf7eca90ebc019d889", 15 | "zh:6e98efe69bff66120cfd0911eea4d20f7b17c62eed909e12098e46efb8f86e5a", 16 | "zh:78f6615113f8fd0fb28f7b58102c55db42e7251463650e0410ec2d6f8877141c", 17 | "zh:8a2f45c2f7e4c4077b720d6b98d5ee26347e97b6d2ee12489cca14d42fef2f36", 18 | "zh:db1a462b72047342d5b35587953a3db30a4af248100e1db9eddc0ebb4b7c9a07", 19 | "zh:fa64449c0efa1340077c860003059762e735b4d2a376d9ece90c174ad8d238c0", 20 | ] 21 | } 22 | 23 | provider "registry.terraform.io/hashicorp/tfe" { 24 | version = "0.28.1" 25 | constraints = ">= 0.25.0" 26 | hashes = [ 27 | "h1:PgumMb80c9XrmvGUES/B3jq7lUkT+b3Oxf1SC+3Ieg0=", 28 | "zh:2c3f6ead7ff5111d2a7747a1167732a7caf6ed1a31e1d15046b54b2f3921aa6f", 29 | "zh:3044820f0bfb5207a87554c7fdf71f3ef08d0dd0c47be1ff855ce3c02f1cf54a", 30 | "zh:328896547cc04fc50df76b5980147e758947a56d498246884ede1fed5f4f6f81", 31 | "zh:3cbe144ecfdf37fd965728ba18b42d0fca8d582d3c367d4f5f06d244271878c7", 32 | "zh:785ec32ab9ede8895e99b8388eaebc13f56f2d14ce94ba2ac5a8b4c265a10432", 33 | "zh:7b64f051f8d49b05746c9108375e7c9298dea5407b06223ed485301b7a608b01", 34 | "zh:a714c4d45bf5a7436feb0376c03e894430553f94bd2bcdc3b9e86486c8b393b8", 35 | "zh:b978c565712f3de3b19989b8a00085bb71325030007ebc69fa9c180d68a135f5", 36 | "zh:eddc6920e530dc17fdb4a1f221215bd612d956322aeabca75bcb9f28294a5e07", 37 | "zh:fc6fb285f116f4110e339a7bd8c4e3a99292db0efbd3aacb7c6c27cf69f3052c", 38 | "zh:fe584a4edb9c3518a520c92cf5cc37fe58d28e469b650de7be3e6bd3be921ff1", 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "hashicorp.terraform", 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.terraform/": true, 4 | }, 5 | "files.insertFinalNewline": true, 6 | "files.trimFinalNewlines": true, 7 | "files.trimTrailingWhitespace": true, 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Ryan Boehning 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloud SQL DB with a Private IP 2 | 3 | This repo demonstrates how to create a Cloud SQL DB with a private IP address 4 | only, and connect to it with [Cloud SQL Proxy](https://cloud.google.com/sql/docs/postgres/sql-proxy). The full explanation of how this works can be found in [this blog post](https://medium.com/@ryanboehning/how-to-deploy-a-cloud-sql-db-with-a-private-ip-only-using-terraform-e184b08eca64). 5 | 6 | Terraform v1.0.0 or higher is required. 7 | 8 | ## How To Use 9 | 10 | 1. Set the name of your Terraform Cloud organization in `backend.tf`. 11 | 12 | 2. Deploy the db and Cloud SQL Proxy 13 | 14 | ```bash 15 | gcloud services enable \ 16 | cloudresourcemanager.googleapis.com \ 17 | compute.googleapis.com \ 18 | iam.googleapis.com \ 19 | oslogin.googleapis.com \ 20 | servicenetworking.googleapis.com \ 21 | sqladmin.googleapis.com 22 | 23 | terraform init 24 | terraform apply 25 | ``` 26 | 27 | 3. Upload your public SSH key to Google's OS Login service 28 | 29 | ```bash 30 | gcloud compute os-login ssh-keys add --key-file=~/.ssh/id_rsa.pub --ttl=365d 31 | ``` 32 | 33 | 4. Connect to the private db through Cloud SQL Proxy 34 | 35 | ```bash 36 | # get your SSH username 37 | gcloud compute os-login describe-profile | grep username 38 | 39 | # get the public IP of the instance running Cloud SQL Proxy 40 | CLOUD_SQL_PROXY_IP=$(terraform output proxy_ip) 41 | 42 | # psql into your private db 43 | ssh -t @$CLOUD_SQL_PROXY_IP docker run --rm --network=host -it postgres:14-alpine psql -U postgres -h localhost 44 | ``` 45 | -------------------------------------------------------------------------------- /backend.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "remote" { 3 | organization = "studybeast-org" 4 | workspaces { 5 | name = "private-ip-cloud-sql-db" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | module "vpc" { 2 | source = "./modules/vpc" 3 | 4 | name = "main-vpc" 5 | } 6 | 7 | module "db" { 8 | source = "./modules/db" 9 | 10 | disk_size = 10 11 | instance_type = "db-f1-micro" 12 | password = var.db_password # This is a variable because it's a secret. It's stored here: https://app.terraform.io/app//workspaces//variables 13 | user = var.db_username 14 | vpc_name = module.vpc.name 15 | vpc_link = module.vpc.link 16 | 17 | # There's a dependency relationship between the db and the VPC that 18 | # terraform can't figure out. The db instance depends on the VPC because it 19 | # uses a private IP from a block of IPs defined in the VPC. If we just giving 20 | # the db a public IP, there wouldn't be a dependency. The dependency exists 21 | # because we've configured private services access. We need to explicitly 22 | # specify the dependency here. For details, see the note in the docs here: 23 | # https://www.terraform.io/docs/providers/google/r/sql_database_instance.html#private-ip-instance 24 | db_depends_on = module.vpc.private_vpc_connection 25 | } 26 | 27 | module "dbproxy" { 28 | source = "./modules/dbproxy" 29 | 30 | machine_type = "f1-micro" 31 | db_instance_name = module.db.connection_name # e.g. my-project:us-central1:my-db 32 | region = var.gcp_region 33 | zone = var.gcp_zone 34 | 35 | # By passing the VPC name as the output of the VPC module we ensure the VPC 36 | # will be created before the proxy. 37 | vpc_name = module.vpc.name 38 | } 39 | -------------------------------------------------------------------------------- /modules/db/main.tf: -------------------------------------------------------------------------------- 1 | // db module 2 | 3 | resource "google_sql_database" "main" { 4 | name = "main" 5 | instance = google_sql_database_instance.main_primary.name 6 | } 7 | 8 | resource "google_sql_database_instance" "main_primary" { 9 | name = "main-primary" 10 | database_version = "POSTGRES_14" 11 | depends_on = [var.db_depends_on] 12 | 13 | settings { 14 | tier = var.instance_type 15 | availability_type = "ZONAL" # use "REGIONAL" for prod to distribute data storage across zones 16 | disk_size = var.disk_size 17 | 18 | ip_configuration { 19 | ipv4_enabled = false # don't give the db a public IPv4 20 | private_network = var.vpc_link # the VPC where the db will be assigned a private IP 21 | } 22 | } 23 | } 24 | 25 | resource "google_sql_user" "db_user" { 26 | name = var.user 27 | instance = google_sql_database_instance.main_primary.name 28 | password = var.password 29 | } 30 | -------------------------------------------------------------------------------- /modules/db/outputs.tf: -------------------------------------------------------------------------------- 1 | // db module 2 | 3 | output "connection_name" { 4 | description = "The connection string used by Cloud SQL Proxy, e.g. my-project:us-central1:my-db" 5 | value = google_sql_database_instance.main_primary.connection_name 6 | } 7 | -------------------------------------------------------------------------------- /modules/db/variables.tf: -------------------------------------------------------------------------------- 1 | // db module 2 | 3 | variable "db_depends_on" { 4 | description = "A single resource that the database instance depends on" 5 | type = any 6 | } 7 | 8 | variable "disk_size" { 9 | description = "The size in GB of the disk used by the db" 10 | type = number 11 | } 12 | 13 | variable "instance_type" { 14 | description = "The instance type of the VM that will run the db (e.g. db-f1-micro, db-custom-8-32768)" 15 | type = string 16 | } 17 | 18 | variable "password" { 19 | description = "The db password used to connect to the Postgers db" 20 | type = string 21 | sensitive = true 22 | } 23 | 24 | variable "user" { 25 | description = "The username of the db user" 26 | type = string 27 | } 28 | 29 | variable "vpc_link" { 30 | description = "A link to the VPC where the db will live (i.e. google_compute_network.some_vpc.self_link)" 31 | type = string 32 | } 33 | 34 | variable "vpc_name" { 35 | description = "The name of the VPC where the db will live" 36 | type = string 37 | } 38 | -------------------------------------------------------------------------------- /modules/dbproxy/main.tf: -------------------------------------------------------------------------------- 1 | 2 | data "google_compute_subnetwork" "regional_subnet" { 3 | name = var.vpc_name 4 | region = var.region 5 | } 6 | 7 | resource "google_compute_instance" "db_proxy" { 8 | name = "db-proxy" 9 | description = <<-EOT 10 | A public-facing instance that proxies traffic to the database. This allows 11 | the db to only have a private IP address, but still be reachable from 12 | outside the VPC. 13 | EOT 14 | machine_type = var.machine_type 15 | zone = var.zone 16 | desired_status = "RUNNING" 17 | allow_stopping_for_update = true 18 | 19 | # Our firewall looks for this tag when deciding whether to allow SSH traffic 20 | # to an instance. 21 | tags = ["ssh-enabled"] 22 | 23 | boot_disk { 24 | initialize_params { 25 | image = "cos-cloud/cos-stable" # latest stable Container-Optimized OS. 26 | size = 10 # smallest disk possible is 10 GB. 27 | type = "pd-ssd" # use an SSD, not an HDD, because c'mon. 28 | } 29 | } 30 | 31 | metadata = { 32 | enable-oslogin = "TRUE" 33 | } 34 | 35 | metadata_startup_script = templatefile("${path.module}/run_cloud_sql_proxy.tpl", { 36 | "db_instance_name" = var.db_instance_name, 37 | "service_account_key" = module.serviceaccount.private_key, 38 | }) 39 | 40 | network_interface { 41 | network = var.vpc_name 42 | subnetwork = data.google_compute_subnetwork.regional_subnet.self_link 43 | 44 | # The access_config block must be set for the instance to have a public IP, 45 | # even if it's empty. 46 | access_config {} 47 | } 48 | 49 | scheduling { 50 | # Migrate to another physical host during OS updates to avoid downtime. 51 | on_host_maintenance = "MIGRATE" 52 | } 53 | 54 | service_account { 55 | email = module.serviceaccount.email 56 | # These are OAuth scopes for the various Google Cloud APIs. We're already 57 | # using IAM roles (specifically, Cloud SQL Editor) to control what this 58 | # instance can and cannot do. We don't need another layer of OAuth 59 | # permissions on top of IAM, so we grant cloud-platform scope to the 60 | # instance. This is the maximum possible scope. It gives the instance 61 | # access to all Google Cloud APIs through OAuth. 62 | scopes = ["cloud-platform"] 63 | } 64 | } 65 | 66 | module "serviceaccount" { 67 | source = "../serviceaccount" 68 | 69 | name = "cloud-sql-proxy" 70 | role = "roles/cloudsql.editor" 71 | } 72 | -------------------------------------------------------------------------------- /modules/dbproxy/outputs.tf: -------------------------------------------------------------------------------- 1 | output "public_ip" { 2 | description = "The public IP of the bastion instance running Cloud SQL Proxy" 3 | value = google_compute_instance.db_proxy.network_interface.0.access_config.0.nat_ip 4 | } 5 | -------------------------------------------------------------------------------- /modules/dbproxy/run_cloud_sql_proxy.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # We write the key to /var because it's one of the few directories that A) is 5 | # writeable, and B) persists between reboots. B is important because GCP will 6 | # automatically reboot the server if it goes down. We don't want to lose the 7 | # key after a reboot. 8 | echo '${service_account_key}' >/var/svc_account_key.json 9 | chmod 444 /var/svc_account_key.json 10 | 11 | # TODO: delete this line and add the `--pull=always` flag to `docker run` 12 | docker pull gcr.io/cloudsql-docker/gce-proxy:latest 13 | 14 | # -p 127.0.0.1:5432:3306 -- cloud_sql_proxy exposes port 3306 on the container, even for Postgres. 15 | # We map 3306 in the container to 5432 on the host. '127.0.0.1' means 16 | # that you can only connect to host port 5432 over localhost. 17 | # -v /var/svc_account_key.json:/key.json:ro -- The file provisioner will copy the service account key file to /key.json 18 | # on the host. We will mount it read-only into the container at the 19 | # same path. 20 | # -ip_address_types=PRIVATE -- The proxy should only try to connect to the db's private IP. 21 | # -instances=${db_instance_name}=tcp:0.0.0.0:3306 -- The instance name will be something like 'my-project:us-central1:my-db'. 22 | # The proxy should accept incoming TCP connections on port 3306. 23 | docker run --rm -p 127.0.0.1:5432:3306 -v /var/svc_account_key.json:/key.json:ro gcr.io/cloudsql-docker/gce-proxy:latest /cloud_sql_proxy -credential_file=/key.json -ip_address_types=PRIVATE -instances=${db_instance_name}=tcp:0.0.0.0:3306 24 | -------------------------------------------------------------------------------- /modules/dbproxy/variables.tf: -------------------------------------------------------------------------------- 1 | // dbproxy module 2 | 3 | variable "db_instance_name" { 4 | description = "The name of the Cloud SQL db, e.g. my-project:us-centra1:my-sql-db" 5 | type = string 6 | } 7 | 8 | variable "machine_type" { 9 | description = "The type of VM you want, e.g. f1-micro, c2-standard-4" 10 | type = string 11 | } 12 | 13 | variable "region" { 14 | description = "The region that the proxy instance will run in (e.g. us-central1)" 15 | type = string 16 | } 17 | 18 | variable "vpc_name" { 19 | description = "The name of the VPC that the proxy instance will run in" 20 | type = string 21 | } 22 | 23 | variable "zone" { 24 | description = "The zone where the VM will be created, e.g. us-centra1-a" 25 | type = string 26 | } 27 | -------------------------------------------------------------------------------- /modules/serviceaccount/main.tf: -------------------------------------------------------------------------------- 1 | // serviceaccount module 2 | data "google_project" "provider" {} 3 | 4 | resource "google_service_account" "account" { 5 | account_id = var.name 6 | description = "The service account used by Cloud SQL Proxy to connect to the db" 7 | } 8 | 9 | resource "google_project_iam_member" "role" { 10 | project = data.google_project.provider.project_id 11 | role = var.role 12 | member = "serviceAccount:${google_service_account.account.email}" 13 | } 14 | 15 | resource "google_service_account_key" "key" { 16 | service_account_id = google_service_account.account.name 17 | } 18 | -------------------------------------------------------------------------------- /modules/serviceaccount/outputs.tf: -------------------------------------------------------------------------------- 1 | 2 | output "email" { 3 | value = google_service_account.account.email 4 | } 5 | 6 | output "private_key" { 7 | value = base64decode(google_service_account_key.key.private_key) 8 | sensitive = true 9 | } 10 | -------------------------------------------------------------------------------- /modules/serviceaccount/variables.tf: -------------------------------------------------------------------------------- 1 | // serviceaccount module 2 | 3 | variable "name" { 4 | description = "The service account name (e.g. cloud-sql-proxy)" 5 | type = string 6 | } 7 | 8 | variable "role" { 9 | description = "The role assigned to the service account (e.g. roles/cloudsql.editor)" 10 | type = string 11 | } 12 | -------------------------------------------------------------------------------- /modules/vpc/main.tf: -------------------------------------------------------------------------------- 1 | // vpc module 2 | 3 | resource "google_compute_network" "vpc" { 4 | name = var.name 5 | routing_mode = "GLOBAL" 6 | auto_create_subnetworks = true 7 | } 8 | 9 | # We need to allocate an IP block for private IPs. We want everything in the VPC 10 | # to have a private IP. This improves security and latency, since requests to 11 | # private IPs are routed through Google's network, not the Internet. 12 | resource "google_compute_global_address" "private_ip_block" { 13 | name = "private-ip-block" 14 | description = "A block of private IP addresses that are accessible only from within the VPC." 15 | purpose = "VPC_PEERING" 16 | address_type = "INTERNAL" 17 | ip_version = "IPV4" 18 | # We don't specify a address range because Google will automatically assign one for us. 19 | prefix_length = 20 # ~4k IPs 20 | network = google_compute_network.vpc.self_link 21 | } 22 | 23 | # This enables private services access. This makes it possible for instances 24 | # within the VPC and Google services to communicate exclusively using internal 25 | # IP addresses. Details here: 26 | # https://cloud.google.com/sql/docs/postgres/configure-private-services-access 27 | resource "google_service_networking_connection" "private_vpc_connection" { 28 | network = google_compute_network.vpc.self_link 29 | service = "servicenetworking.googleapis.com" 30 | reserved_peering_ranges = [google_compute_global_address.private_ip_block.name] 31 | } 32 | 33 | # We'll need this to connect to the Cloud SQL Proxy. 34 | resource "google_compute_firewall" "allow_ssh" { 35 | name = "allow-ssh" 36 | description = "Allow SSH traffic to any instance tagged with 'ssh-enabled'" 37 | network = google_compute_network.vpc.name 38 | direction = "INGRESS" 39 | 40 | allow { 41 | protocol = "tcp" 42 | ports = ["22"] 43 | } 44 | 45 | source_ranges = ["0.0.0.0/0"] 46 | target_tags = ["ssh-enabled"] 47 | } 48 | -------------------------------------------------------------------------------- /modules/vpc/outputs.tf: -------------------------------------------------------------------------------- 1 | // vpc module 2 | 3 | output "link" { 4 | description = "A link to the VPC resource, useful for creating resources inside the VPC" 5 | value = google_compute_network.vpc.self_link 6 | } 7 | 8 | output "name" { 9 | description = "The name of the VPC" 10 | value = google_compute_network.vpc.name 11 | } 12 | 13 | output "private_vpc_connection" { 14 | description = "The private VPC connection" 15 | value = google_service_networking_connection.private_vpc_connection 16 | } 17 | -------------------------------------------------------------------------------- /modules/vpc/variables.tf: -------------------------------------------------------------------------------- 1 | // vpc module 2 | 3 | variable "name" { 4 | description = "The name of the VPC to create" 5 | type = string 6 | } 7 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "proxy_ip" { 2 | description = <<-EOT 3 | The public IP of the instance running Cloud SQL Proxy. psql into this 4 | instance to connect to your private db. 5 | EOT 6 | value = module.dbproxy.public_ip 7 | } 8 | -------------------------------------------------------------------------------- /providers.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | project = var.gcp_project_name 3 | region = var.gcp_region 4 | zone = var.gcp_zone 5 | } 6 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "db_password" { 2 | description = "The Postgres password" 3 | type = string 4 | sensitive = true 5 | } 6 | 7 | variable "db_username" { 8 | description = "The Postgres username" 9 | type = string 10 | } 11 | 12 | variable "gcp_project_name" { 13 | description = "The name of the GCP project where the db and Cloud SQL Proxy will be created" 14 | type = string 15 | } 16 | 17 | variable "gcp_region" { 18 | description = "The GCP region where the db and Cloud SQL Proxy will be created" 19 | type = string 20 | } 21 | 22 | variable "gcp_zone" { 23 | description = "The GCP availability zone where the db and Cloud SQL Proxy will be created" 24 | type = string 25 | } 26 | -------------------------------------------------------------------------------- /versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.0.0" 3 | 4 | required_providers { 5 | tfe = { 6 | source = "hashicorp/tfe" 7 | version = ">= 0.25.0" 8 | } 9 | google = { 10 | source = "hashicorp/google" 11 | version = ">= 3.70.0" 12 | } 13 | } 14 | } 15 | --------------------------------------------------------------------------------