An OpenTofu module that provisions a complete, production-oriented application environment on UpCloud: private networking with NAT, a load balancer, multiple application servers, managed PostgreSQL with PgBouncer, managed Valkey, S3-compatible object storage, encrypted shared file storage, and a peered management network.
Only the load balancer is public. Every server and managed service sits on a private network with no public IP.
flowchart TB
user([Users]) -->|HTTP| lb[Load balancer<br/>the only public resource]
subgraph private["Private network · 10.0.10.0/24"]
app1[App server 1]
app2[App server 2]
pg[(PostgreSQL + PgBouncer)]
vk[(Valkey)]
obj[Object storage]
fs[File storage]
app1 & app2 --> pg
app1 & app2 --> vk
app1 & app2 --> obj
app1 & app2 --> fs
end
lb --> app1
lb --> app2
private -->|NAT gateway, outbound only| internet([Internet])
private <-->|Peering| mgmt[Management network<br/>10.0.20.0/24]
- OpenTofu 1.6 or later (Terraform should also work, but this module is tested with OpenTofu)
- UpCloud provider
UpCloudLtd/upcloud~> 5.0 - An UpCloud account with a payment method. Trial accounts allow only one managed database and can't modify firewalls, so this module won't apply on a trial.
- An UpCloud API sub-account with credentials exported as
UPCLOUD_USERNAMEandUPCLOUD_PASSWORD
provider "upcloud" {}
module "fullstack" {
source = "github.com/Aahil13/terraform-upcloud-fullstack?ref=v1.0.0"
name_prefix = "myapp"
db_password = var.db_password
}export UPCLOUD_USERNAME="your-api-username"
export UPCLOUD_PASSWORD="your-api-password"
export TF_VAR_db_password='something-long-and-random'
tofu init
tofu applyA full build takes around 25 minutes. Managed databases and object storage are the slow parts, and there's no progress output while they provision. Don't interrupt the apply. An interrupted apply leaves resources that exist in UpCloud but not in your state.
A complete working configuration is in examples/basic.
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
name_prefix |
Prefix for every resource name. Also used as the database, pool, bucket, and share name. | string |
yes | |
db_password |
Password for the application database user | string |
yes | |
zone |
UpCloud zone for all zonal resources | string |
"fi-hel1" |
no |
object_storage_region |
Object storage region. Must include zone in its zone list. |
string |
"europe-1" |
no |
cidr |
Address range for the application network | string |
"10.0.10.0/24" |
no |
mgmt_cidr |
Address range for the management network. Must not overlap cidr. |
string |
"10.0.20.0/24" |
no |
server_count |
Number of application servers | number |
2 |
no |
server_plan |
UpCloud plan for the application servers | string |
"1xCPU-2GB" |
no |
db_plan |
Managed PostgreSQL plan | string |
"1x1xCPU-2GB-25GB" |
no |
valkey_plan |
Managed Valkey plan | string |
"1x1xCPU-2GB" |
no |
pool_mode |
PgBouncer pool mode | string |
"transaction" |
no |
pool_size |
PgBouncer pool size | number |
10 |
no |
| Name | Description |
|---|---|
lb_dns |
Public DNS name of the load balancer |
app_private_ips |
Private IPs of the application servers |
network_id |
UUID of the application network |
mgmt_network_id |
UUID of the peered management network |
pool_uri |
PgBouncer connection URI (sensitive). Use this rather than db_host and db_port. |
db_host |
PostgreSQL hostname, direct connection |
db_port |
PostgreSQL port, direct connection |
db_name |
Application database name |
valkey_host |
Valkey hostname |
valkey_port |
Valkey port |
bucket_name |
Object storage bucket name |
s3_endpoint |
Private S3-compatible endpoint |
file_share |
NFS share name |
nfs_host |
NFS server address on the private network |
Each application server boots with cloud-init, which:
- Writes every connection detail to
/etc/app.env, readable only by root:DATABASE_URL(through PgBouncer),VALKEY_HOST,VALKEY_PORT,VALKEY_PASSWORD,S3_ENDPOINT,S3_BUCKET,S3_ACCESS_KEY,S3_SECRET_KEY, andSHARED_PATH. - Mounts the shared file storage at
/mnt/sharedthrough/etc/fstab, so it survives reboots. - Installs nginx and serves a placeholder page, so the load balancer's health checks have something to hit.
Replace the nginx placeholder in modules/compute/main.tf with your application's install.
Each part of the architecture is its own module under modules/ and can be used on its own:
| Module | Creates |
|---|---|
network |
Private network, router, NAT gateway |
compute |
Application servers with cloud-init |
loadbalancer |
Load balancer, backend, members, frontend, health checks |
database |
PostgreSQL, logical database, user, PgBouncer pool, Valkey |
storage |
Object storage, bucket, user, access key, scoped policy |
filestorage |
Encrypted file storage, NFS share, access list |
peering |
Management network and bidirectional peering |
- A NAT gateway alone doesn't give private servers internet access. The network also needs
dhcp_default_route = true, which this module sets. - Object storage takes a region, not a zone. The region's zone list must include your zone.
- Managed databases get assigned ports, not 5432 or 6379. Always use the outputs.
- Connecting with
db_hostanddb_portbypasses PgBouncer without any error. Usepool_uri. - Managed databases on a private network can't migrate zones. Choose your zone before the first apply.
- File storage encryption can only be set at creation.
- Peering must exist in both directions before traffic flows. This module creates both.
- The object storage bucket can't be emptied by OpenTofu. If
tofu destroyfails on the bucket, delete its objects first.
- No application server has a public interface. The load balancer is the only ingress.
- Every managed service has public access disabled.
- Credentials marked
sensitiveare hidden from terminal output but stored in plain text in state, in the server metadata service, and in/etc/app.env. Use an encrypted remote backend for state. OpenTofu supports native state encryption from 1.7. - Access to servers is through the UpCloud web console, using the root password emailed at creation.
- The load balancer listens on HTTP only. Add a certificate bundle and a 443 frontend before production use.
- The health check hits
/and expects a 200. - File storage is fixed at 250 GB.
- Replacing the servers, for example after changing
user_data, replaces them all at once, with a short outage.
Most of these services bill hourly. Run tofu destroy when you're not using the environment. One tofu apply brings it back.
MIT. See LICENSE.