{"id":10491,"date":"2026-11-11T12:46:57","date_gmt":"2026-11-11T12:46:57","guid":{"rendered":"https:\/\/upcloud.com\/global\/?post_type=tutorial&#038;p=10491"},"modified":"2026-11-11T12:46:57","modified_gmt":"2026-11-11T12:46:57","slug":"opentofu-cloud-infrastructure","status":"publish","type":"tutorial","link":"https:\/\/upcloud.com\/global\/resources\/tutorials\/opentofu-cloud-infrastructure\/","title":{"rendered":"How to Build Full-Stack Infrastructure as Code with OpenTofu &#8211; Part 1"},"content":{"rendered":"\n<h1 class=\"wp-block-heading\"><\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Have you ever tried building a full-stack infrastructure-as-code setup?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Well, I have, and it was quite the journey.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I had intended to deploy a multi-server application on UpCloud, with a load balancer in front, a managed database, a cache, object storage for uploads, and a shared filesystem.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">However, there were no good tutorials available, and I kept finding guides that stopped at a single server, whereas I wanted to go further. Even <a href=\"https:\/\/upcloud.com\/global\/\">UpCloud<\/a>, the provider I was building on, wasn&#8217;t covered by the guides I found.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Since I couldn&#8217;t find a good one, I decided to write one myself and build it as a set of reusable modules.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In this tutorial, I will walk you through the process of building a full-stack infrastructure-as-code setup using OpenTofu.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide will cover the essential components needed for a robust deployment, including private networking, load balancers, managed databases, caching, object storage, and shared file storage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Added to that are decisions I made and the reasoning behind them, so you can learn from my experience and apply it to your own projects.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If this sounds fun, then let&#8217;s get started!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Build a full-stack infrastructure-as-code setup on UpCloud using OpenTofu: private networking, a NAT gateway, a load balancer, two app servers, managed PostgreSQL and Valkey, S3-compatible object storage, shared file storage, and network peering.<\/li>\n\n\n\n<li>The completed modules can be found in this <a href=\"https:\/\/github.com\/Anita-ihuman\/terraform-upcloud-fullstack\" target=\"_blank\" rel=\"noopener\">GitHub repository<\/a><\/li>\n\n\n\n<li>Clone the repository, cd examples\/basic, then run tofu init, tofu plan, and tofu apply<\/li>\n\n\n\n<li>The modules are generic. Set name_prefix, and you can deploy any application.<\/li>\n\n\n\n<li>A full build takes around 25 minutes. A full teardown takes about the same.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Stack<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>OpenTofu 1.6 or later<\/li>\n\n\n\n<li>UpCloud provider UpCloudLtd\/upcloud, version ~&gt; 5.0<\/li>\n\n\n\n<li>Seven modules: network, compute, loadbalancer, database, storage, filestorage, peering<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>To run it<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Export UPCLOUD_USERNAME, UPCLOUD_PASSWORD, and TF_VAR_db_password<\/li>\n\n\n\n<li>Run tofu init, tofu plan, then tofu apply<\/li>\n\n\n\n<li>Run tofu destroy when you&#8217;re done, since most of these services bill hourly<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Things to look out for<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Trial accounts allow one managed database and block firewall changes. Add a payment method first.<\/li>\n\n\n\n<li>A NAT gateway alone doesn&#8217;t give private servers internet access. The network also needs dhcp_default_route = true.<\/li>\n\n\n\n<li>Cloud-init needs metadata = true on the server, and package installs need retries.<\/li>\n\n\n\n<li>Object storage takes a region (like europe-1), not a zone (like fi-hel1).<\/li>\n\n\n\n<li>Managed databases get assigned ports, not 5432. Always use the output values.<\/li>\n\n\n\n<li>Keep every zoned resource in the same zone. Managed databases on a private network can&#8217;t migrate zones.<\/li>\n\n\n\n<li>File storage encryption can only be turned on when the storage is created.<\/li>\n\n\n\n<li>Network peering has to be created in both directions before traffic flows.<\/li>\n\n\n\n<li>Valkey connections use TLS. Configure your app with rediss:\/\/ or its equivalent TLS option, not plain redis:\/\/.<\/li>\n\n\n\n<li>Don&#8217;t interrupt an apply. It leaves resources that OpenTofu no longer tracks.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Prerequisites<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/opentofu.org\/\" target=\"_blank\" rel=\"noopener\">OpenTofu 1.6<\/a> or later installed.<\/li>\n\n\n\n<li>An UpCloud account with a payment method added.<\/li>\n\n\n\n<li>An <a href=\"https:\/\/upcloud.com\/global\/docs\/guides\/getting-started-upcloud-api\/\">UpCloud API sub-account<\/a>, so as not to use your main login creds.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;re new to OpenTofu on UpCloud, the <a href=\"https:\/\/upcloud.com\/global\/resources\/tutorials\/opentofu-on-upcloud-hands-on-tutorial\/\">OpenTofu on UpCloud hands-on tutorial <\/a>is a good starting point. It covers provisioning a single server, configuring remote state, and working with the UpCloud provider before moving on to a larger deployment.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Architecture overview<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before we write any code, let&#8217;s look at what we&#8217;re building.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">flowchart TB<br>user([Users]) &#8211;&gt;|HTTP| lb[Load balancer&lt;br\/&gt;the only public resource]<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">subgraph private[&#8220;Private network \u00b7 10.0.10.0\/24&#8221;]\n    app1[App server 1]\n    app2[App server 2]\n    pg[(PostgreSQL + PgBouncer)]\n    vk[(Valkey)]\n    obj[Object storage]\n    fs[File storage]\n    app1 &amp; app2 &#8211;&gt; pg\n    app1 &amp; app2 &#8211;&gt; vk\n    app1 &amp; app2 &#8211;&gt; obj\n    app1 &amp; app2 &#8211;&gt; fs\nend\n\nlb &#8211;&gt; app1\nlb &#8211;&gt; app2\nprivate &#8211;&gt;|NAT gateway, outbound only| internet([Internet])\nprivate &lt;&#8211;&gt;|Peering| mgmt[Management network&lt;br\/&gt;10.0.20.0\/24]\n<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/opentofu-upcloud-infrastructure-architecture-1024x823.png\" alt=\"Architecture diagram showing an UpCloud load balancer, two private application servers, PostgreSQL, Valkey, object storage, file storage, a NAT gateway, and a peered management network.\" class=\"wp-image-90064\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>NOTE<\/strong>: This part builds the load balancer, the app servers, and the private network they sit on, along with the NAT gateway that gets them outbound access. The managed services and the peered network come in part 2.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything starts at the load balancer. It&#8217;s the only resource in this setup with a public IP address. When a request comes in, the load balancer checks which app servers are healthy and forwards the request to one of them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The app servers sit on a private network, and they don&#8217;t have public IPs, so there&#8217;s no way for anyone to reach them directly from the internet. The only way in is through the load balancer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Behind the app servers are the managed services, and each one has a specific job:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>PostgreSQL:<\/strong> This stores the application&#8217;s data like users, accounts, and file metadata.<\/li>\n\n\n\n<li><strong>Valkey:<\/strong> Handles caching and sessions. Once you have more than one app server, this stops being optional, because a user&#8217;s session has to be available to whichever server handles their next request. It&#8217;s also where shared locks live, so two servers don&#8217;t write to the same file at once.<\/li>\n\n\n\n<li><strong>Object storage:<\/strong> Holds uploaded files, so it doesn&#8217;t matter which server received the upload.<\/li>\n\n\n\n<li><strong>File storage:<\/strong> Gives every app server the same shared filesystem for config and application files.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">All four are only reachable from the private network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The app servers still need to reach the internet to install packages. That&#8217;s what the NAT gateway is for. Traffic can go out through it, but nothing can come in.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, there&#8217;s a second private network connected through peering. In this setup, it represents a management network, basically, the kind of place you&#8217;d put monitoring or admin tools. It shows how to connect two networks without exposing either one to the internet.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What you&#8217;ll have at the end<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When the build finishes, OpenTofu prints everything you need to connect an application, which includes the load balancer&#8217;s DNS name, the private IPs of both servers, and the hosts and ports for PostgreSQL, Valkey, object storage, and the file share.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Curl the load balancer a few times, and you&#8217;ll see responses alternating between both app servers. That one test proves the load balancer, the health checks, the private network, and the server setup all work together.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Project structure<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Each part of the architecture is its own module:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">.<br>\u251c\u2500\u2500 main.tf<br>\u251c\u2500\u2500 variables.tf<br>\u251c\u2500\u2500 versions.tf<br>\u2514\u2500\u2500 modules\/<br>          \u251c\u2500\u2500 network\/<br>          \u251c\u2500\u2500 compute\/<br>          \u251c\u2500\u2500 loadbalancer\/<br>          \u251c\u2500\u2500 database\/<br>          \u251c\u2500\u2500 storage\/<br>          \u251c\u2500\u2500 filestorage\/<br>          \u2514\u2500\u2500 peering\/<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every module has the same four files: versions.tf, variables.tf, main.tf, and outputs.tf. The root main.tf connects them by passing one module&#8217;s outputs into another&#8217;s inputs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>NOTE<\/strong>: The example app I was deploying is Nextcloud, so that&#8217;s the `name_prefix` throughout. You can change `name_prefix` to `myapp` and you get `myapp-private` and a `myapp-data` bucket instead.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Setting up the project<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before we create any infrastructure, there are three decisions that shaped how I built this:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>How OpenTofu authenticates<\/li>\n\n\n\n<li>How the modules declare their provider<\/li>\n\n\n\n<li>Where the zone is defined<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Credentials<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The UpCloud provider reads UPCLOUD_USERNAME and UPCLOUD_PASSWORD from the environment, so the provider block can be completely empty:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">export UPCLOUD_USERNAME=&#8221;your-api-username&#8221;<br>export UPCLOUD_PASSWORD=&#8221;your-api-password&#8221;<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Providers<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In the project root, create a versions.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">terraform {<br>required_version = &#8220;&gt;= 1.6&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">required_providers {<br>upcloud = {<br>source = &#8220;UpCloudLtd\/upcloud&#8221;<br>version = &#8220;~&gt; 5.0&#8221;<br>}<br>}<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">provider &#8220;upcloud&#8221; {}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The ~&gt; 5.0 constraint allows any 5.x release but not 6.0, so you get fixes without a major version changing resource behaviour.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each module in modules\/ gets a versions.tf with the same required_providers block but no provider block. The root configures the provider and the modules only declare it, which is what keeps them reusable.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Zone<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Create a variables.tf file in the root:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;zone&#8221; {<br>description = &#8220;UpCloud zone for all resources&#8221;<br>type = string<br>default = &#8220;fi-hel1&#8221;<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every module takes zone = var.zone. Managed databases must sit in the same zone as the private network they attach to, and once attached they can&#8217;t migrate zones. So a zone mismatch between modules shows up as a plan that tries to rebuild things.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu init before moving on.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Networking<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The network module creates the private network, a router, and a NAT gateway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To set this up, create a modules\/network\/variables.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;name_prefix&#8221; {<br>type = string<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;zone&#8221; {<br>type = string<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;cidr&#8221; {<br>type = string<br>default = &#8220;10.0.10.0\/24&#8221;<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then create a modules\/network\/main.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">resource &#8220;upcloud_router&#8221; &#8220;this&#8221; {<br>name = &#8220;${var.name_prefix}-router&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">lifecycle {<br>ignore_changes = [static_route]<br>}<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">resource &#8220;upcloud_network&#8221; &#8220;private&#8221; {<br>name = &#8220;${var.name_prefix}-private&#8221;<br>zone = var.zone<br>router = upcloud_router.this.id<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">ip_network {<br>address = var.cidr<br>dhcp = true<br>dhcp_default_route = true<br>dhcp_dns = [&#8220;1.1.1.1&#8221;, &#8220;8.8.8.8&#8221;]<br>family = &#8220;IPv4&#8221;<br>}<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">resource &#8220;upcloud_gateway&#8221; &#8220;this&#8221; {<br>name = &#8220;${var.name_prefix}-gateway&#8221;<br>zone = var.zone<br>features = [&#8220;nat&#8221;]<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">router {<br>id = upcloud_router.this.id<br>}<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And finally, a modules\/network\/outputs.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;network_id&#8221; {<br>value = upcloud_network.private.id<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;router_id&#8221; {<br>value = upcloud_router.this.id<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then, in the root main.tf file, call the module and add an output so the network ID is visible after the apply:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">module &#8220;network&#8221; {<br>source = &#8220;.\/modules\/network&#8221;<br>name_prefix = &#8220;nextcloud&#8221;<br>zone = var.zone<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;network_id&#8221; {<br>value = module.network.network_id<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;re coming from AWS, note that there&#8217;s no separate subnet resource. The ip_network block is the subnet, so one network means one address range. The CIDR is a variable because peering later needs non-overlapping ranges.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Outbound access<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The app servers have no public IP, but they still need to install packages, and that&#8217;s the NAT gateway&#8217;s job.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A gateway with features = [&#8220;nat&#8221;] lets the router translate outbound traffic, and it doesn&#8217;t tell the servers to send traffic there.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Without dhcp_default_route = true, DHCP never gives the servers a default route, so anything outside 10.0.10.0\/24 has nowhere to go. The servers boot normally, cloud-init fails to install anything, and the only visible symptom is the load balancer returning 503 because no backend is serving (I can&#8217;t tell you how many hours I spent debugging this one).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The ignore_changes on the router exists for a related reason. The gateway adds its own static route to the router behind the scenes. Without the lifecycle rule, OpenTofu sees that route as drift and removes it on the next apply.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu apply.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The gateway takes a couple of minutes to provision, and when it finishes, you should see:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Apply complete! Resources: 3 added, 0 changed, 0 destroyed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Outputs:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">network_id = &#8220;036656aa-b67e-4b07-999b-80441b8ba056&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Your ID will be different.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the UpCloud control panel, open Networks and select nextcloud-private. The overview should show 10.0.10.0\/24 with DHCP enabled.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/upcloud-private-network-dhcp-1024x679.png\" alt=\"UpCloud Control Panel showing the nextcloud-private network with the 10.0.10.0\/24 address range, gateway 10.0.10.1, and DHCP enabled.\" class=\"wp-image-90056\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>UpCloud network overview showing the private network with DHCP enabled<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then check the Routing tab. You&#8217;ll see a 0.0.0.0\/0 route. That&#8217;s the route the gateway added, and the one the ignore_changes rule protects.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/upcloud-nat-gateway-default-route-1024x328.png\" alt=\"UpCloud routing table showing the default route 0.0.0.0\/0 created by the NAT gateway.\" class=\"wp-image-90067\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Routing tab showing the default route added by the NAT gateway<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Servers<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The compute module creates the app servers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To do this, create a modules\/compute\/variables.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;name_prefix&#8221; {<br>type = string<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;zone&#8221; {<br>type = string<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;network_id&#8221; {<br>type = string<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;plan&#8221; {<br>type = string<br>default = &#8220;1xCPU-2GB&#8221;<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;server_count&#8221; {<br>type = number<br>default = 2<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then a modules\/compute\/main.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">resource &#8220;upcloud_server&#8221; &#8220;app&#8221; {<br>count = var.server_count<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">hostname = &#8220;${var.name_prefix}-app-${count.index + 1}&#8221;<br>zone = var.zone<br>plan = var.plan<br>metadata = true<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">template {<br>storage = &#8220;Ubuntu Server 24.04 LTS (Noble Numbat)&#8221;<br>size = 25<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">network_interface {<br>type = &#8220;private&#8221;<br>network = var.network_id<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">login {<br>user = &#8220;root&#8221;<br>create_password = true<br>password_delivery = &#8220;email&#8221;<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">user_data = &lt;&lt;-EOF<br>#!\/bin\/bash<br>set -x<br>for i in 1 2 3 4 5; do<br>apt-get update &amp;&amp; apt-get install -y nginx &amp;&amp; break<br>sleep 15<br>done<br>echo &#8220;hello from $(hostname)&#8221; &gt; \/var\/www\/html\/index.html<br>systemctl enable &#8211;now nginx<br>EOF<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And finally, a modules\/compute\/outputs.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;private_ips&#8221; {<br>value = [for s in upcloud_server.app : s.network_interface[0].ip_address]<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Go back to the root directory, and in the main.tf file, call the compute module and add an output for the private IPs:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">module &#8220;compute&#8221; {<br>source = &#8220;.\/modules\/compute&#8221;<br>name_prefix = &#8220;nextcloud&#8221;<br>zone = var.zone<br>network_id = module.network.network_id<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;app_private_ips&#8221; {<br>value = module.compute.private_ips<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The nginx page is a placeholder so the load balancer has something to check. We&#8217;ll replace the user_data with real connection details later.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Access<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The servers have a single private interface, so there&#8217;s no public IP and no SSH from your machine. The fallback is the web console in the control panel, which is why the login block generates a root password and emails it to you.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You can&#8217;t have both. UpCloud rejects create_password = true alongside SSH keys, so pick one per server. With no public interface, a key has nothing to connect to, so the password is the option to go with.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">metadata = true is required too. Cloud-init reads user_data from UpCloud&#8217;s metadata service, and the API refuses to create a server from a cloud-init template without it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Cloud-init<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I added the retry loop because both servers boot at the same moment and hit the package mirrors together, and on one run, one of them failed to install nginx.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cloud-init runs once, and nothing supervises it, so that failure was permanent. Anything in user_data that touches the network should assume it&#8217;ll sometimes fail.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu apply and when it finishes, you should see:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Apply complete! Resources: 2 added, 0 changed, 0 destroyed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Outputs:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">app_private_ips = [<br>&#8220;10.0.10.2&#8221;,<br>&#8220;10.0.10.3&#8221;,<br>]<br>network_id = &#8220;036656aa-b67e-4b07-999b-80441b8ba056&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the control panel, open Servers. You&#8217;ll see nextcloud-app-1 and nextcloud-app-2.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/upcloud-two-private-app-servers-1024x279.png\" alt=\"UpCloud Control Panel listing nextcloud-app-1 and nextcloud-app-2, running Ubuntu 24.04 with private IP addresses 10.0.10.5 and 10.0.10.6.\" class=\"wp-image-90060\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Clicking on any of them will reveal they have no public IP, only a private one.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/opentofu-private-app-servers-upcloud-1024x501.png\" alt=\"Network settings for nextcloud-app-1 showing a single private network interface with IP address 10.0.10.5 and no public IP address.\" class=\"wp-image-90058\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>UpCloud control panel showing two servers with only private IPs<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Load balancer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The load balancer module creates the only public resource in the build.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To set it up, create a modules\/loadbalancer\/variables.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;name_prefix&#8221; {<br>type = string<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;zone&#8221; {<br>type = string<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;network_id&#8221; {<br>type = string<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">variable &#8220;backend_ips&#8221; {<br>type = list(string)<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then a modules\/loadbalancer\/main.tf file:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/opentofu-upcloud-load-balancer-configuration-968x1024.png\" alt=\"OpenTofu code defining an UpCloud load balancer with private and public IPv4 network interfaces.\" class=\"wp-image-90055\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">resource &#8220;upcloud_loadbalancer&#8221; &#8220;this&#8221; {<br>name = &#8220;${var.name_prefix}-lb&#8221;<br>zone = var.zone<br>plan = &#8220;development&#8221;<br>configured_status = &#8220;started&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">networks {<br>name = &#8220;private-net&#8221;<br>type = &#8220;private&#8221;<br>family = &#8220;IPv4&#8221;<br>network = var.network_id<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">networks {<br>name = &#8220;public-net&#8221;<br>type = &#8220;public&#8221;<br>family = &#8220;IPv4&#8221;<br>}<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">resource &#8220;upcloud_loadbalancer_backend&#8221; &#8220;this&#8221; {<br>loadbalancer = upcloud_loadbalancer.this.id<br>name = &#8220;${var.name_prefix}-backend&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">properties {<br>health_check_type = &#8220;http&#8221;<br>health_check_url = &#8220;\/&#8221;<br>health_check_interval = 10<br>health_check_fall = 3<br>health_check_rise = 2<br>health_check_expected_status = 200<br>timeout_server = 30<br>}<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">resource &#8220;upcloud_loadbalancer_static_backend_member&#8221; &#8220;app&#8221; {<br>count = length(var.backend_ips)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">backend = upcloud_loadbalancer_backend.this.id<br>name = &#8220;${var.name_prefix}-member-${count.index}&#8221;<br>ip = var.backend_ips[count.index]<br>port = 80<br>weight = 100<br>max_sessions = 1000<br>enabled = true<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">resource &#8220;upcloud_loadbalancer_frontend&#8221; &#8220;this&#8221; {<br>loadbalancer = upcloud_loadbalancer.this.id<br>name = &#8220;${var.name_prefix}-frontend&#8221;<br>mode = &#8220;http&#8221;<br>port = 80<br>default_backend_name = upcloud_loadbalancer_backend.this.name<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">networks {<br>name = upcloud_loadbalancer.this.networks[1].name<br>}<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And then a modules\/loadbalancer\/outputs.tf file:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;public_dns&#8221; {<br>value = upcloud_loadbalancer.this.networks[1].dns_name<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the root main.tf file, call the module and add an output for the DNS name:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">module &#8220;loadbalancer&#8221; {<br>source = &#8220;.\/modules\/loadbalancer&#8221;<br>name_prefix = &#8220;nextcloud&#8221;<br>zone = var.zone<br>network_id = module.network.network_id<br>backend_ips = module.compute.private_ips<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">output &#8220;lb_dns&#8221; {<br>value = module.loadbalancer.public_dns<br>}<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">backend_ips takes the server list straight from the compute module. Change server_count, and the backend members follow, with no edits to this module.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Public access<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s no public network resource on UpCloud. Public access is a networks block with type = &#8220;public&#8221; on whatever needs it, and in this build that&#8217;s only the load balancer. It sits on both networks: public to receive traffic, private to reach the servers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The frontend and the DNS output both use networks[1], which is the public block because it&#8217;s declared second. That index is positional because if you reorder the blocks, both point to the private network. The DNS name is read from the network block rather than the top-level dns_name attribute, which is deprecated because a load balancer can now have more than one network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This listens on plain HTTP. For production, add a certificate bundle and a frontend on 443. The provider supports both automatic and manual bundles.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Health checks<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">fall = 3 and rise = 2 are deliberately different. A server is pulled after 30 seconds of failures but brought back after 20 seconds of success, so a single slow response doesn&#8217;t halve your capacity and a flapping server doesn&#8217;t keep rejoining.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run tofu apply and when it finishes:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Apply complete! Resources: 5 added, 0 changed, 0 destroyed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Outputs:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">app_private_ips = [<br>&#8220;10.0.10.2&#8221;,<br>&#8220;10.0.10.3&#8221;,<br>]<br>lb_dns = &#8220;lb-0a7026fbfb434a388e1b23b9dd0cc42d-2.upcloudlb.com&#8221;<br>network_id = &#8220;036656aa-b67e-4b07-999b-80441b8ba056&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t curl it straight away. An apply finishing means UpCloud accepted the resources, not that the servers are serving. For the first few minutes, you&#8217;ll get a 503, which is the health check correctly refusing to send traffic to servers still running cloud-init.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Give it three to four minutes, then:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">curl -H &#8220;Connection: close&#8221; http:\/\/&lt;your-lb-dns&gt;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run it a few times. The response should alternate between hello from nextcloud-app-1 and hello from nextcloud-app-2. The Connection: close header forces a fresh connection each time, so you&#8217;re testing the balancing rather than a reused connection.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the control panel, open Load balancers, select nextcloud-lb, and check the backend. Both members should show as Up. If one is Down while the other serves, it&#8217;s a server that didn&#8217;t finish cloud-init, not a load balancer problem. That&#8217;s the first place to look when traffic only ever reaches one backend.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/upcloud-load-balancer-healthy-backends-1024x690.png\" alt=\"UpCloud Control Panel showing two load balancer backend members with Up status, private IP addresses, and port 80.\" class=\"wp-image-90061\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>UpCloud control panel showing the load balancer with both backend members up<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">At this point, you have a private network with a NAT gateway for outbound access, two app servers with no public IP at all, and a load balancer in front of them that health-checks both and distributes traffic between them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">However, it has nowhere to put anything. The servers serve a placeholder page, and nothing persists.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Part 2 fixes that. We&#8217;ll add managed PostgreSQL with connection pooling, Valkey for caching and sessions, object storage for uploads, an encrypted shared filesystem that both servers can mount, and a peered management network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then we&#8217;ll wire every connection detail into the servers through cloud-init, and finish with how to manage and secure the whole thing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Before you go, two things. If you&#8217;re stopping here for now, run `tofu destroy`, since the gateway and the load balancer bill hourly, and one `tofu apply` brings everything back.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And the complete modules for both parts are in the <a href=\"https:\/\/github.com\/Anita-ihuman\/terraform-upcloud-fullstack\" target=\"_blank\" rel=\"noopener\">GitHub repository<\/a> if you&#8217;d rather read ahead.<\/p>\n","protected":false},"author":83,"featured_media":0,"comment_status":"open","ping_status":"closed","template":"","community-category":[256,274,259,235],"class_list":["post-10491","tutorial","type-tutorial","status-publish","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/10491","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=10491"}],"version-history":[{"count":3,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/10491\/revisions"}],"predecessor-version":[{"id":10508,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/10491\/revisions\/10508"}],"wp:attachment":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/media?parent=10491"}],"wp:term":[{"taxonomy":"community-category","embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/community-category?post=10491"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}