{"id":10306,"date":"2026-11-13T13:57:33","date_gmt":"2026-11-13T11:57:33","guid":{"rendered":"https:\/\/upcloud.com\/global\/?post_type=tutorial&#038;p=10306"},"modified":"2026-11-13T12:04:26","modified_gmt":"2026-11-13T12:04:26","slug":"opentofu-modules-databases-storage","status":"publish","type":"tutorial","link":"https:\/\/upcloud.com\/global\/resources\/tutorials\/opentofu-modules-databases-storage\/","title":{"rendered":"How to Build Full-Stack Infrastructure as Code with OpenTofu &#8211; Part 2"},"content":{"rendered":"\n<h1 class=\"wp-block-heading\"><\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">In <a href=\"https:\/\/upcloud.com\/global\/resources\/tutorials\/opentofu-cloud-infrastructure\/\">part 1<\/a>, we built the foundation, with a private network with a NAT gateway, two application servers with no public IP, and a load balancer distributing traffic between them. You curl the load balancer, and you get a response from one server, then the other.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What those servers don&#8217;t have is anywhere to put anything. They serve a placeholder page, and nothing they do persists.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So in this part we&#8217;ll add the state. We will set up a managed PostgreSQL with a connection pool, Valkey for caching and sessions, S3-compatible object storage for uploads, an encrypted shared filesystem both servers can mount, and a second network connected through peering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then we&#8217;ll wire every connection detail into the servers through cloud-init.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Let&#8217;s pick up where we left off.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Prerequisites<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Everything from part 1, still deployed. If you ran `tofu destroy`, run `tofu apply` to bring it back before continuing.<\/li>\n\n\n\n<li>`UPCLOUD_USERNAME` and `UPCLOUD_PASSWORD` exported in your current shell. Environment variables don&#8217;t survive a new terminal, so if you&#8217;ve closed yours since part 1, set them again.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Databases<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The database module creates managed PostgreSQL with a connection pool and Valkey.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To set this up, create a modules\/database\/variables.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/opentofu-database-module-variables-786x1024.png\" alt=\"OpenTofu database module variables defining PostgreSQL credentials, Valkey settings, and connection pool configuration.\" class=\"wp-image-90132\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then add a modules\/database\/main.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/opentofu-managed-postgresql-resource-1024x834.png\" alt=\"OpenTofu configuration creating a managed PostgreSQL database attached to a private UpCloud network.\" class=\"wp-image-90133\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Find the complete main.tf file on GitHub <a href=\"https:\/\/github.com\/Aahil13\/terraform-upcloud-fullstack\/blob\/main\/modules\/database\/main.tf\" target=\"_blank\" rel=\"noopener\">here<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, add a modules\/database\/outputs.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/opentofu-postgresql-valkey-module-outputs-977x1024.png\" alt=\"OpenTofu module outputs for PostgreSQL and Valkey hostnames, ports, credentials, and the connection pool URI.\" class=\"wp-image-90134\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Add the password variable to the root variables.tf, with no default so OpenTofu refuses to run without it:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;db_password&#8221; {<br>type = string<br>sensitive = true<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Set it through the environment rather than a .tfvars file that could end up committed:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">export TF_VAR_db_password=&#8217;something-long-and-random&#8217;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then in the root main.tf file, call the database module and add outputs for the hostnames, ports, and pool:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image14-712x1024.png\" alt=\"-\" class=\"wp-image-90190\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Ports and hostnames<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Managed databases on UpCloud get an assigned port, not 5432 or 6379. Mine came back as 11569 and 11570. Anything that assumes the default port will fail, which is why the ports are outputs rather than constants.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The hosts are hostnames rather than IPs. They resolve to private addresses, and those addresses can change during maintenance or failover while the hostname stays the same. Always hand the hostname to your application.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Notice the two passwords are handled differently. You supply PostgreSQL&#8217;s, and Valkey generates its own, so the module has to read it back out. Marking it sensitive keeps it out of terminal output, but it still sits in plain text in the state file. More on that in the security section.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Valkey also only accepts TLS connections. Configure your app with rediss:\/\/ or its TLS option, or it&#8217;ll fail to connect even with the right host, port, and password.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Connection pooling<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">On UpCloud, PgBouncer isn&#8217;t a server you run. Connection pooling is built into managed PostgreSQL, and the provider exposes it as a resource, so the pool lives in the same module as the database.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">database and username reference the other resources instead of repeating their names as strings. That&#8217;s what makes OpenTofu create the pool after the database and user exist. With plain strings, it could try to build all three at once and fail. Setting username also restricts the pool to that one user.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The default mode is transaction, the usual choice for web apps with short requests. A server connection is only held for the length of a transaction, so a small pool serves many clients. The trade-off is that anything relying on session state, like session-level prepared statements or advisory locks, won&#8217;t work reliably. Check your application before using it. session mode is the safe fallback.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The pool&#8217;s URI points to the same host as PostgreSQL, but a different port, and its database name is the pool&#8217;s name rather than the real database. Build your connection string from db_host and db_name, and you&#8217;ll connect directly, bypassing the pool without any error. Use the URI.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu apply.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both services build in parallel, since neither depends on the other, and each takes five to ten minutes, and there&#8217;s no progress output during that time. Don&#8217;t interrupt it!<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When it finishes:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image24-1024x690.png\" alt=\"-\" class=\"wp-image-90193\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">OpenTofu lists pool_uri but hides its value. Read it with tofu output -raw pool_uri, and you&#8217;ll see the pool&#8217;s port and name in place of the direct ones.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the control panel, open Databases. Both services should show as running with the private network attached. Then go back to your network&#8217;s Routing tab. You&#8217;ll find a new route to 100.125.24.0\/22 with the source &#8220;Network peering.&#8221; You didn&#8217;t create it, but UpCloud wires managed databases into your private network using the same peering mechanism we&#8217;ll use ourselves later.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image10-1024x523.png\" alt=\"-\" class=\"wp-image-90195\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>UpCloud control panel showing the managed PostgreSQL and Valkey services running<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Object storage<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The storage module creates an S3-compatible object storage service, a bucket, and a user with scoped access.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To set this up, create a modules\/storage\/variables.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image20-1024x530.png\" alt=\"-\" class=\"wp-image-90197\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then add a modules\/storage\/main.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image19-1024x716.png\" alt=\"-\" class=\"wp-image-90198\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Find the complete main.tf file on GitHub <a href=\"https:\/\/github.com\/Aahil13\/terraform-upcloud-fullstack\/blob\/main\/modules\/storage\/main.tf\" target=\"_blank\" rel=\"noopener\">here<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, add a modules\/storage\/outputs.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image5-1024x783.png\" alt=\"-\" class=\"wp-image-90201\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then in the root main.tf file, call the module and add outputs:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image13-1024x592.png\" alt=\"-\" class=\"wp-image-90203\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Regions<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Object storage takes a region, not a zone, and they aren&#8217;t interchangeable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A region like europe-1 spans many zones, and its private network attachment only works if your network&#8217;s zone is one of them. To see the options, read the upcloud_managed_object_storage_regions data source.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each region lists its zones and a primary_zone, which is where the data physically lives. I picked europe-1 because its primary zone is fi-hel2, next to everything else in Helsinki.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Access policies<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A user with an access key can&#8217;t touch anything until a policy is attached. The policy is a standard S3-style IAM document, and the provider wants it URL-encoded, so jsonencode builds it, and urlencode wraps it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The policy is scoped to one bucket rather than the whole service. Within that bucket, it allows s3:*, because applications that use multipart uploads break in hard-to-trace ways when the action list is too narrow. The description is immutable after creation, so changing it later replaces the policy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If the arn:aws:s3 prefix looks odd, S3 is a protocol here, not a vendor. Any S3 client works against UpCloud once you point it at the endpoint, which is also why the access key IDs look like AWS ones.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Private endpoint<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The service exposes its endpoints as a set of objects, each with a type of private or public. The for expression in the output picks the private one. You&#8217;ll know it&#8217;s right because the hostname contains -private.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu apply. The service takes around ten minutes, with the bucket, user, key, and policies taking a second each once they exist.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When it finishes:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image24-1-1024x690.png\" alt=\"-\" class=\"wp-image-90204\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">In the control panel, open Object Storage. You&#8217;ll see the service with the nextcloud-data bucket, and the nextcloud user with the nextcloud-bucket-access policy attached.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image12-1024x393.png\" alt=\"-\" class=\"wp-image-90206\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>UpCloud control panel showing the object storage service<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">File storage<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The file storage module creates an encrypted NFS share that every app server can mount.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, add one output to modules\/network\/outputs.tf, so the share&#8217;s access list can use the network&#8217;s range instead of repeating it as a string:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;cidr&#8221; {<br>value = var.cidr<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then create a modules\/filestorage\/variables.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image2-2-1024x617.png\" alt=\"-\" class=\"wp-image-90212\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then add a modules\/filestorage\/main.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image17-1024x783.png\" alt=\"-\" class=\"wp-image-90214\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Find the complete main.tf file on GitHub <a href=\"https:\/\/github.com\/Aahil13\/terraform-upcloud-fullstack\/blob\/main\/modules\/filestorage\/main.tf\" target=\"_blank\" rel=\"noopener\">here<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, add a modules\/filestorage\/outputs.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image9-1024x429.png\" alt=\"-\" class=\"wp-image-90242\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then, in the root main.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image22-1024x617.png\" alt=\"-\" class=\"wp-image-90243\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Encryption<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">encrypt = true can only be set when the storage is created. Add it later, and OpenTofu has to rebuild the whole service, so decide up front.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Access list<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The share exists as soon as it&#8217;s created, but nothing can mount it without an ACL entry. The target here is the whole private network range rather than individual server IPs, so the ACL doesn&#8217;t need touching when you scale the servers. The trade-off is that anything on the subnet can mount it, which in this build is only the app servers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Also notice the network block has no type field, unlike every other service in this build. File storage only attaches to private networks, and only one at a time.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Mounting<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">File storage takes an address on your private network, just like a server, and which address it gets depends on what was created first. When I rebuilt everything from scratch, file storage came up before the servers and took 10.0.10.2, which pushed the servers to .5 and .6.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s one more reason nothing here hardcodes an IP. The mount target comes from the nfs_host output, and the servers mount &lt;nfs_host&gt;:\/nextcloud, which we&#8217;ll add to cloud-init when we wire everything together.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu apply, and when it finishes, you&#8217;ll see 3 added, plus the new outputs:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">file_share = &#8220;nextcloud&#8221;<br>nfs_host = &#8220;10.0.10.2&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your address will differ.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the control panel, open File Storage. You&#8217;ll see the service with the nextcloud share and one ACL entry for 10.0.10.0\/24 with read-write access.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image21-1024x297.png\" alt=\"-\" class=\"wp-image-90246\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>UpCloud control panel showing the file storage service<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Peering<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The peering module creates a second private network and connects it to the first. Nothing in this build needs it to run.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It stands in for a management network, the kind of place you&#8217;d put monitoring or admin tools, and shows how to connect two networks without exposing either to the internet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To set this up, create a modules\/peering\/variables.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image7-1024x641.png\" alt=\"-\" class=\"wp-image-90248\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then add a modules\/peering\/main.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image8.png\" alt=\"-\" class=\"wp-image-90251\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Find the complete main.tf file on GitHub <a href=\"https:\/\/github.com\/Aahil13\/terraform-upcloud-fullstack\/blob\/main\/modules\/peering\/main.tf\" target=\"_blank\" rel=\"noopener\">here<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, add a modules\/peering\/outputs.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;mgmt_network_id&#8221; {<br>value = upcloud_network.mgmt.id<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then in the root main.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image23-1024x494.png\" alt=\"-\" class=\"wp-image-90253\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Bidirectional connection<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A peering on UpCloud only becomes active when it exists from both sides. That&#8217;s why there are two resources with the networks swapped. The mechanism is designed for connecting networks across different accounts, where each side has to agree independently. When both networks are yours, it feels redundant, but create only one, and you get a peering that shows as created and carries no traffic.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The ranges can&#8217;t overlap, which is why the management network uses 10.0.20.0\/24 against the app network&#8217;s 10.0.10.0\/24, and why the CIDR has been a variable since the network module.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you ever remove a peering by hand in the control panel, it has to be disabled before it can be deleted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu apply, and when it finishes, you&#8217;ll see 4 added and the new mgmt_network_id output.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the control panel, open your app network&#8217;s Routing tab. You&#8217;ll see a route to 10.0.20.0\/24 with the source &#8220;Network peering&#8221; and the name nextcloud-app-to-mgmt.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Under the DHCP routes, the same route appears as auto-populated, with 10.0.10.1 as the next hop, which is how servers on the app network learn to reach the management network.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image1-1024x759.png\" alt=\"-\" class=\"wp-image-90256\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>UpCloud control panel showing the app network&#8217;s routing table with a peering route to the management network<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Wiring it together<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every service now exists, but the app servers don&#8217;t know about any of them. This section passes the connection details into the servers through cloud-init, and mounts the shared file storage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add these to modules\/compute\/<a href=\"http:\/\/variables.tf\" target=\"_blank\" rel=\"noopener\">variables.tf<\/a>:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image15-1024x830.png\" alt=\"-\" class=\"wp-image-90259\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Find the complete variables.tf file on GitHub <a href=\"https:\/\/github.com\/Aahil13\/terraform-upcloud-fullstack\/blob\/main\/modules\/compute\/variables.tf\" target=\"_blank\" rel=\"noopener\">here<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then replace the user_data in modules\/compute\/<a href=\"http:\/\/main.tf\" target=\"_blank\" rel=\"noopener\">main.tf<\/a>:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image6-1024x764.png\" alt=\"-\" class=\"wp-image-90262\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then pass everything in from the root main.tf. The compute module block becomes:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image11-1024x617.png\" alt=\"-\" class=\"wp-image-90265\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">The dependency graph<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The compute module now references outputs from three other modules, so OpenTofu builds the database, storage, and file storage before the servers. There&#8217;s no depends_on anywhere in this project. The references are enough.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Through the pool<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The database connection is a single DATABASE_URL built from the pool URI, not a host and port. That&#8217;s deliberate. As the database section showed, connecting with the direct host bypasses the pool without any error, so the servers never receive the direct details at all.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Two kinds of ${}<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Inside the heredoc, ${var.nfs_host} is HCL. OpenTofu replaces it before the script ever reaches the server. $(hostname) is shell and runs on the server at boot. If you need a literal ${&#8230;} in a script, for a shell variable with braces, escape it as $${&#8230;}, or OpenTofu will try to interpolate it and fail.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The mount<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The share goes into \/etc\/fstab rather than a one-off mount command, so it survives a reboot. _netdev tells the system to wait for the network before mounting, which matters on a server whose only network is the private one. The mount runs after the package loop, because nfs-common has to be installed first.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Replacing servers<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">user_data only runs at first boot, so changing it replaces the servers. Both get replaced in the same apply, which means a short outage while the new ones boot and pass their health checks. For a real deployment, look at create_before_destroy or roll the servers one at a time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu plan first. You should see both servers replaced and the load balancer&#8217;s backend members updated to the new IPs, and nothing else. Then apply, and give it about five minutes before you curl the load balancer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To confirm the wiring, open the console for either server and run:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">cat \/etc\/app.env<br>df -h \/mnt\/shared<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first should show real values on every line, not ${var&#8230;}. The second should show the NFS share from your nfs_host mounted at \/mnt\/shared.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Managing the infrastructure<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Building this was half the job because there were some things that caught me while I was running this.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Drift<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The moment you change something in the control panel, the next plan shows it as drift, with OpenTofu proposing to put it back. That&#8217;s usually what you want.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When the platform changes something on purpose, like the gateway adding its route to the router, ignore_changes tells OpenTofu to leave it alone. And if a resource exists but isn&#8217;t in state, tofu import adopts it instead of recreating it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">State<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">By default, the state is a local file, and it holds every secret in this build in plain text. That&#8217;s fine while you&#8217;re learning. However, for anything shared, move it to a remote backend, which also stops two people from applying at once.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Modules and environments<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The repo&#8217;s root is a module that calls the seven submodules, and examples\/basic calls the root. That&#8217;s also how you&#8217;d separate environments: one root configuration each for staging and production, each calling the same module with its own name_prefix, CIDR, and plans, and each with its own state. Pin the module with ?ref=v1.0.0, so an update to the module never changes your production infrastructure by surprise.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Security<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most of the security in this build isn&#8217;t about my configuration but about structure.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Firewall rules<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The app servers have no firewall rules, and that&#8217;s deliberate. <a href=\"https:\/\/upcloud.com\/global\/docs\/products\/networking\/firewall\/#public--utility-firewall\">UpCloud&#8217;s firewall filters public and utility<\/a> interfaces, and these servers have neither.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Rules on them would filter nothing. The only way in is the load balancer, which forwards to the backends you named on the port you configured.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Early in the build, my first server had a public IP, and I wrote firewall rules for it. Once the load balancer took over and the public interface came off, the rules had nothing left to guard.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Removing the exposure is a stronger guarantee than a rule, because a rule can be misconfigured and a missing interface can&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every managed service follows the same pattern. The databases have public_access = false, object storage only exposes its private endpoint, and the file share&#8217;s access list only allows the private network&#8217;s range.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Where the secrets end up<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Marking a variable or output sensitive only keeps it out of the terminal and plan output. In this build, the database password, the Valkey password, the pool URI, and the object storage keys all end up in three other places:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The state file, in plain text.<\/li>\n\n\n\n<li>The server&#8217;s metadata service, because they&#8217;re part of user_data. Any process on the server can read them.<\/li>\n\n\n\n<li>\/etc\/app.env on each server, readable only by root.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>NOTE<\/strong>: Don&#8217;t pass long-lived credentials through `user_data` in production. Use a secret-management system, or workload identity where the platform offers it, and have the application fetch credentials at runtime. The gain is rotation: the app can fetch a new credential each time without rebuilding the server, while one baked into `user_data` is replaced only when the server is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the state file, keep it in a remote backend, never in git, and encrypt it. OpenTofu can encrypt state natively from version 1.7, which is one of the features Terraform doesn&#8217;t have.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Access<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s no SSH into the servers, only the web console with the emailed root password. For regular access, the usual answer is a bastion host with a public IP and tight firewall rules, on the same private network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Or skip access entirely: put everything in cloud-init and rebuild instead of logging in. That&#8217;s closer to the point of infrastructure as code anyway. A fix made by hand over SSH is a fix your code doesn&#8217;t know about.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And use an API sub-account for OpenTofu, not your main login. It can be restricted, and it can be revoked without locking you out.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Across both parts, we started with nothing and ended with a load balancer in front of two private app servers, managed PostgreSQL with connection pooling, Valkey, object storage, an encrypted shared filesystem, and a peered management network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/github.com\/Anita-ihuman\/terraform-upcloud-fullstack\" target=\"_blank\" rel=\"noopener\">GitHub repository<\/a> packages this build as a reusable module. The root of the repo is the module itself, and examples\/basic shows how to call it, which is the fastest way to stand up your own copy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything here runs on a standard <a href=\"https:\/\/upcloud.com\/global\/\">UpCloud<\/a> account, and the provider covers far more than we touched. If you&#8217;re still on Terraform, <a href=\"https:\/\/opentofu.org\/\" target=\"_blank\" rel=\"noopener\">OpenTofu<\/a> is worth the switch, if only for native state encryption.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you want to explore more ways to automate and manage infrastructure on UpCloud, check out our <a href=\"https:\/\/upcloud.com\/global\/resources\/tutorials\/?cat=terraform\">Terraform tutorials<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"author":83,"featured_media":0,"comment_status":"open","ping_status":"closed","template":"","community-category":[256,226,268,232,265,235],"class_list":["post-10306","tutorial","type-tutorial","status-publish","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/10306","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial"}],"about":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/types\/tutorial"}],"author":[{"embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/users\/83"}],"replies":[{"embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/comments?post=10306"}],"version-history":[{"count":7,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/10306\/revisions"}],"predecessor-version":[{"id":10545,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/10306\/revisions\/10545"}],"wp:attachment":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/media?parent=10306"}],"wp:term":[{"taxonomy":"community-category","embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/community-category?post=10306"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}