Reading

Deep dive into the topic

Built and Host a Rust Leptos Web App for Under 1€


Rust Leptos TailwindCSS SSR Web Azure
Blog Introduction Image

1. Introduction

Published on: 2025-04-08

This blog chronicles my journey of learning how to build SSR and CSR web applications with Rust Leptos. The whole setup is currently costing me only 0.05 €/month at over 600 requests/month. My initial plan was to host everything on Azure, make the whole infrastructure and software deployment fully automated and pay as less money as possible. I achieved 2 out of the 3 goals, let me take you on this little journey and maybe I can spare you some pain if you have the same plan in mind.

2. Target Architecture

After doing some research and trying out different approaches on Azure, just to realize that costs can explode very fast, the following architecture is what I finally came up with. I know that there are way easier ways to host a web application, but my goal was to learn and deploy the full set up by myself.

Target Architecture

Basically the concept is pretty simple: I host my loading page (CSR Rust Leptos App) on Cloudflare Pages for free. This is a static web page and gets updated whenever there is a new commit on my branch of choice (in this case main for production). The user always makes a request to my loading page and the loading page then makes a request to my main page (SSR Rust Leptos App) hosted on Azure Conatiner Apps. If my main page is ready to be served, the loading page will redirect the user to it and therefor to my main content. The rest of the architecture is mainly for the cost controll of the Azure resources. A budget is applied on the resource group level. When the costs are reaching a certain threshold, an Azure Action group is triggered which is running an Azure Runbook job that will stop the Azure Container App.

3. Providing the Infrastructure

The Azure infrastructure is provided by Pulumi which is a tool to write infrastructure as code in your favorite programming language... (I mean, almost. Rust is not supported yet). I chose C# for this project. Let me shortly explain you the main components of the infrastructure based on the code below.

            
using System.Collections.Generic;
using Pulumi;
using AzureNative = Pulumi.AzureNative;

using personal_website_resources.Resources;

return await Pulumi.Deployment.RunAsync(() =>
{
    // ------------------------------------------------------------------------
    // Init
    // ------------------------------------------------------------------------
    var stackName = Deployment.Instance.StackName;
    var configAzure = new Config("azure-native");
    var configPWR = new Config("personal-website-resources");
    
    // ------------------------------------------------------------------------
    // Secrets from ENV. Only for deploy. Set: CR_PAT, CR_USER_NAME, 
    // CERTIFICATE_PASSWORD, CLOUDFLARE_API_TOKEN
    // ------------------------------------------------------------------------
    var secrets = new DeploymentSecrets();
    
    // ------------------------------------------------------------------------
    // Deploy the dev stack
    // ------------------------------------------------------------------------
    if (stackName == "dev")
    {
        // -----------------------
        // Dev specific
        // -----------------------
        var resourceGroup = new AzureNative.Resources.ResourceGroup("resource-
            group-" + stackName, new()
        {
            Location = configAzure.Get("location"),
            ResourceGroupName = "rg-personal-website-" + stackName,
            Tags = new Dictionary<string, string>
            {
                { "Managed by", "Pulumi" },
            },
        });
        
        var devStackApp = new AzureContainerApp(
            stackName,
            resourceGroup.Name,
            configAzure.Get("location"),
            5000,
            secrets.CrUsername,
            secrets.CrPassword,
            configPWR.Get("imageName"),
            secrets.CertificatePassword,
            "DOMAIN_PLACEHOLDER_BACKEND",
            "DOMAIN_PLACEHOLDER_FRONTEND"
        );

        var _devBudget = new Budget(
            stackName,
            resourceGroup.Name,
            resourceGroup.Id,
            devStackApp.containerAppId,
            devStackApp.containerAppName,
            configAzure.Get("location")
            );
    }
    // ------------------------------------------------------------------------
    // Deploy the prd stack
    // ------------------------------------------------------------------------
    else if (stackName == "prd")
    {
        // -----------------------
        // Prd specific
        // -----------------------
        var resourceGroup = new AzureNative.Resources.ResourceGroup("resource-
            group-" + stackName, new()
        {
            Location = "west europe",
            ResourceGroupName = "rg-personal-website-" + stackName,
            Tags = new Dictionary<string, string>
            {
                { "Managed by", "Pulumi" },
            },
        });
        
        var devStackApp = new AzureContainerApp(
            stackName,
            resourceGroup.Name,
            configAzure.Get("location"),
            5000,
            secrets.CrUsername,
            secrets.CrPassword,
            configPWR.Get("imageName"),
            secrets.CertificatePassword,
            "DOMAIN_PLACEHOLDER_BACKEND",
            "DOMAIN_PLACEHOLDER_FRONTEND"
        );

        var _devBudget = new Budget(
            stackName,
            resourceGroup.Name,
            resourceGroup.Id,
            devStackApp.containerAppId,
            devStackApp.containerAppName,
            configAzure.Get("location")
        );
    }
});
                
            

The code above is the main entry point of the Pulumi project. The code checks the stack name (dev or prd) and creates a resource group, a container app, and a budget for the resource group. The container app is configured with the image name, location, and other parameters. Let me explain the components of my AzureContainerApp Class.

3.1 My AzureContainerApp Class

            
using System;
using System.IO;
using Pulumi.AzureNative.App;
using Pulumi;
using Pulumi.Azure.Core;
using Pulumi.AzureNative.App.Inputs;
using Pulumi.AzureNative.CodeSigning;
using Pulumi.AzureNative.OperationalInsights;
using Pulumi.AzureNative.OperationalInsights.Inputs;
using Cloudflare = Pulumi.Cloudflare;
using Azure = Pulumi.Azure;
using Pulumi.Command;

namespace personal_website_resources.Resources;

// This class deploys all required resources for Azure Container App
public class AzureContainerApp
{
    // Add outputs
    public Output<string> containerAppId { get; set; }
    public Output<string> containerAppName { get; set; }
    
    public AzureContainerApp(
        string environment, // pulumi stack
        Output<string> resourceGroupName,
        string location,
        int appPort,
        string crUsername, // Container registry
        string crPassword, // Container registry
        string imageName, // Image
        string certificatePassword, // For SSL certificate
        string backendDomain,
        string frontendDomain,
        string dnsZone = "MY_ZONE_NAME_PLACEHOLDER",
        double cpu = 0.25, // Memory for the container app
        string memory = "0.5Gi" // Memory for the container app
    )
    {
        // Create a Log Analytics Workspace (required for Container Apps environment)
        var workspace = new Workspace("log-analytics-workspace-" + environment, new()
        {
            Location = location,
            ResourceGroupName = resourceGroupName,
            RetentionInDays = 30,
            Sku = new WorkspaceSkuArgs
            {
                Name = WorkspaceSkuNameEnum.PerGB2018,
            },
            WorkspaceName = "law-personal-app-" + environment,
        });

        // Get shared key for Log Analytics Workspace
        var workspaceSharedKeys = Output.Tuple(resourceGroupName, workspace.Name).Apply(items =>
            GetSharedKeys.InvokeAsync(
                new GetSharedKeysArgs
                {
                    ResourceGroupName = items.Item1,
                    WorkspaceName = items.Item2,
                }));

        // Create managed environment for container apps
        var managedEnvironment = new ManagedEnvironment("managed-environment-" + environment, new()
        {
            ResourceGroupName = resourceGroupName,
            EnvironmentName = "me-personal-website-" + environment,
            Location = location,
            AppLogsConfiguration = new AppLogsConfigurationArgs
            {
                Destination = "log-analytics",
                LogAnalyticsConfiguration = new LogAnalyticsConfigurationArgs
                {
                    CustomerId = workspace.CustomerId,
                    SharedKey = workspaceSharedKeys.Apply(r => r.PrimarySharedKey)
                },
            },
        });

        // Create certificate after the managed environment
        var certificate = new Certificate("certificate-" + environment, new()
        {
            CertificateName = "personal-website",
            EnvironmentName = managedEnvironment.Name,
            Location = managedEnvironment.Location,
            Properties = new CertificatePropertiesArgs
            {
                Password = certificatePassword,
                Value = Convert.ToBase64String(File.ReadAllBytes("certificate/certificate.pfx")),
            },
            ResourceGroupName = resourceGroupName,
        });

        // Create a Container App
        var containerApp = new ContainerApp("container-app-" + environment, new()
        {
            ResourceGroupName = resourceGroupName,
            EnvironmentId = managedEnvironment.Id,
            Location = location,
            ContainerAppName = "ca-personal-website-" + environment,
            Configuration = new ConfigurationArgs
            {
                Ingress = new IngressArgs
                {
                    External = true,
                    TargetPort = appPort,
                    Transport = "http",
                    AllowInsecure = true,
                    CorsPolicy = new CorsPolicyArgs
                    {
                        AllowedOrigins = new[]
                        {
                            ("https://" + frontendDomain + "." + dnsZone),
                            ("https://" + backendDomain + "." + dnsZone)
                        },
                        AllowCredentials = true,
                        AllowedMethods = new[] { "GET", "POST", "PUT", "DELETE", "OPTIONS" },
                        AllowedHeaders = new[] { "*" },
                    }
                },
                Registries = new InputList<RegistryCredentialsArgs>()
                {
                    new RegistryCredentialsArgs
                    {
                        Server = "REGISTRY_PLACEHOLDER",
                        Username = crUsername,
                        PasswordSecretRef = "crpassword",
                    },
                },
                Secrets =
                {
                    new SecretArgs
                    {
                        Name = "crpassword",
                        Value = crPassword,
                    }
                },
            },
            Template = new TemplateArgs
            {
                Containers = new[]
                {
                    new ContainerArgs
                    {
                        Image = imageName,
                        Name = "personal-website-" + environment,
                        Resources = new ContainerResourcesArgs()
                        {
                            Cpu = cpu,
                            Memory = memory
                        }
                    },
                },
                Scale = new ScaleArgs()
                {
                    MinReplicas = 0,
                    MaxReplicas = 2
                }
            },
        });
        
        // Add Cloudflare record
        var txtRecord = new Cloudflare.DnsRecord("asuid-txt-" + environment, new Cloudflare.DnsRecordArgs()
        {
            ZoneId = "ZONE_ID_PLACEHOLDER",
            Name = $"asuid." + backendDomain,
            Type = "TXT",
            Content = containerApp.CustomDomainVerificationId,
            Ttl = 300,
        });

        // Retrieve the fqdn from the container app
        var fqdn = resourceGroupName.Apply(Name => containerApp.Name.Apply(caName => containerApp.Id.Apply(appId =>
            Output.Create(Pulumi.AzureNative.App.GetContainerApp.InvokeAsync(new GetContainerAppArgs
            {
                ContainerAppName = caName,
                ResourceGroupName = Name
            }))).Apply(app => app.Configuration?.Ingress?.Fqdn)));


        // Add a CNAME to Cloudflare so I can resolve my custom Domain to the App
        var cNameRecord = new Cloudflare.DnsRecord("cname-backend-" + environment, new()
            {
                ZoneId = "ZONE_ID_PLACEHOLDER",
                Name = backendDomain,
                Type = "CNAME",
                Content = fqdn,
                Ttl = 1,
                Proxied = false,
            }
        );
        
        // Set outpus
        containerAppId = containerApp.Id;
        containerAppName = containerApp.Name;
    }
}
                
            

These are the main components of the AzureContainerApp class, and they are required to deploy your Container App with the needed features. There are just a couple of things I want to highlight. First of all, the created certificate for my DNS zone is added to the managed environment of the container app. This is required to use a custom domain with SSL. The certificate is created from a PFX file that I generated before deploying the infrastructure with Certbot. Secondly, and this is the most annoying part, this set up cannot be fully automated. The problem is that you cannot add your custom domain to the container app, this has to be done manually after the deployment finished. The reason for this is that you have to verify the domain ownership by adding a TXT record to your DNS zone (You can follow this Github Issue for more info). You will face this error also on other Azure services and I honestly do not understand why Microsoft is not fixing this. The last thing I want to mention is the Scale attributes of the container app. If you set the MinReplicas to 0, the container app will not be running all the time and you will save some costs and it is more sustainable. This is the main trick, to put your container to sleep when it is not needed. If a request comes in, the container is automatically started. I currently have an cold start time of circa 4 seconds.

3.2 My Budget Class

The budget class is used to create a budget for the resource group. This is required to stop the container app when the budget is reached. The budget is created with a threshold of 5 € and will stop the container app when the budget is reached. The code below shows how the budget class is implemented.

            
using System;
using Pulumi;
using Pulumiverse.Time;

namespace personal_website_resources.Resources;

public class Budget
{
    public Budget(
        string environment, // pulumi stack
        Output<string> resourceGroupName,
        Output<string> resourceGroupId,
        Output<string> containerAppId,
        Output<string> containerAppName,
        string location
    )
    {
        // Automation Account
        var automationAccountResource = new Pulumi.AzureNative.Automation.AutomationAccount(
            "automation-account-" + environment, new()
            {
                ResourceGroupName = resourceGroupName,
                AutomationAccountName = "aa-personal-website-" + environment,
                Name = "aa-personal-website-" + environment,
                Identity = new Pulumi.AzureNative.Automation.Inputs.IdentityArgs
                {
                    Type = Pulumi.AzureNative.Automation.ResourceIdentityType.SystemAssigned,
                },
                Location = location,
                PublicNetworkAccess = true,
                Sku = new Pulumi.AzureNative.Automation.Inputs.SkuArgs
                {
                    Name = Pulumi.AzureNative.Automation.SkuNameEnum.Free,
                },
            });

        // Create a Runbook
        var runbook = new Pulumi.AzureNative.Automation.Runbook("runbook-stop-container-app-" + environment, new()
        {
            AutomationAccountName = automationAccountResource.Name,
            Description = "Used for triggered the container app stop on budget alert",
            Location = location,
            LogActivityTrace = 1,
            LogProgress = true,
            LogVerbose = false,
            Name = "Stop-Container-App",
            ResourceGroupName = resourceGroupName,
            RunbookName = "Stop-Container-App",
            RunbookType = Pulumi.AzureNative.Automation.RunbookTypeEnum.PowerShell72,
        });
        
        // Add a delay after runbook creation (e.g., 30 seconds)
        var runbookDelay = new Sleep("runbook-delay-" + environment, new SleepArgs
        {
            CreateDuration = "5s",
        }, new CustomResourceOptions
        {
            DependsOn = { runbook }
        });
        
        var generateScript = new Pulumi.Command.Local.Command("generate-runbook-script-" + environment, new Pulumi.Command.Local.CommandArgs
        {
            Create = Output.Tuple(resourceGroupName, containerAppName).Apply(names =>
            {
                var scriptPath = "./utils/runbook002.ps";
                var tempScriptPath = "./utils/temp_runbook.ps";
                var scriptContentRaw = System.IO.File.ReadAllText(scriptPath);
                var rgName = names.Item1;
                var caName = names.Item2;
                var content = scriptContentRaw
                    .Replace("RG_NAME", rgName)
                    .Replace("CA_NAME", caName);
                // Write the content to the temp file
                System.IO.File.WriteAllText(tempScriptPath, content);
                return $"echo Script generated for {rgName}/{caName}";
            }),
        });
        
        // Prepare the cli command from outputs
        var scriptContent = Output.Format($@"
            az automation runbook replace-content \
              --automation-account-name {automationAccountResource.Name} \
              --resource-group {resourceGroupName} \
              --name {runbook.Name} \
              --content @utils/temp_runbook.ps \
            ");

        var uploadScript = new Pulumi.Command.Local.Command("upload-runbook-script",
            new Pulumi.Command.Local.CommandArgs
            {
                Create = scriptContent
            }, new CustomResourceOptions()
            {
                DependsOn = { runbookDelay, generateScript }
            });

        // Publish the runbook
        var scriptContentPublish = Output.Format($@"
            az automation runbook publish \
              --automation-account-name {automationAccountResource.Name} \
              --resource-group {resourceGroupName} \
              --name {runbook.Name} \
            ");
        
        // Add a delay after runbook creation (e.g., 30 seconds)
        var runbookDelayUpload = new Sleep("runbook-delay-upload-" + environment, new SleepArgs
        {
            CreateDuration = "10s",
        }, new CustomResourceOptions
        {
            DependsOn = { runbook, uploadScript }
        });

        var publishScript = new Pulumi.Command.Local.Command("publish-runbook-script-" + environment,
            new Pulumi.Command.Local.CommandArgs
            {
                Create = scriptContentPublish
            }, new CustomResourceOptions()
            {
                DependsOn = { uploadScript, runbookDelayUpload }
            });
        
        // Create a Action Group
        var actionGroupResource = new Pulumi.AzureNative.Monitor.ActionGroup("action-group-" + environment, new()
        {
            Enabled = true,
            ResourceGroupName = resourceGroupName,
            GroupShortName = "AGPW",
            ActionGroupName = "ag-personal-website-" + environment,
            Location = "global",
        });

        // Get the first day of the current month in UTC
        var firstDayOfCurrentMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);

        // Format as ISO 8601 string
        var startDateString = firstDayOfCurrentMonth.ToString("yyyy-MM-ddTHH:mm:ssZ");

        // Create a budget
        var budget = new Pulumi.AzureNative.CostManagement.Budget("budget-" + environment, new()
        {
            Category = "Cost",
            BudgetName = "personal-website-budget-" + environment,
            Amount = 5,
            TimeGrain = "Monthly",
            Scope = resourceGroupId,
            TimePeriod = new Pulumi.AzureNative.CostManagement.Inputs.BudgetTimePeriodArgs
            {
                StartDate = startDateString,
            },
            Notifications =
            {
                {
                    "Actual_GreaterThan_80_Percent", new Pulumi.AzureNative.CostManagement.Inputs.NotificationArgs
                    {
                        ContactEmails = new[]
                        {
                            "YOUR_EMAIL_PLACEHOLDER",
                        },
                        ContactGroups = new[]
                        {
                            actionGroupResource.Id,
                        },
                        Enabled = true,
                        Locale = Pulumi.AzureNative.CostManagement.CultureCode.En_us,
                        Operator = Pulumi.AzureNative.CostManagement.BudgetNotificationOperatorType.GreaterThan,
                        Threshold = 80,
                        ThresholdType = Pulumi.AzureNative.CostManagement.ThresholdType.Actual,
                    }
                },
            },
        });

        // Role assignment
        var roleAssignment = new Pulumi.AzureNative.Authorization.RoleAssignment("roleAssignment", new()
        {
            PrincipalId = automationAccountResource.Identity.Apply(identity => identity.PrincipalId),
            PrincipalType = Pulumi.AzureNative.Authorization.PrincipalType.ServicePrincipal,
            RoleDefinitionId =
                "/providers/Microsoft.Authorization/roleDefinitions/358470bc-b998-42bd-ab17-a7e34c199c0f",
            Scope = containerAppId,
        });
    }
}
                
            

The budget class creates a budget for the resource group and sends an email notification when the budget is reached. The budget is created with a threshold of 5 € and will stop the container app when the budget is reached. There are just two things I want to highlight. You need to create the role assignment so that the automation account has the permissions to stop the container app. The second thing is that you also need a manual step here: You need to add the run book to the action group. As for now, this is not possible via the Azure API or CLI.

4. Rust Leptos Code

Leptos provides a bunch of examples that you can use to get started. I used the tailwind_actix base for my SSR main app, and the tailwind_csr base for my CSR loading page. Almost everything you need to know about Leptos is documented in the Leptos documentation. I will not walk you through my code, I just want to highlight some aspects of Leptos which made my life as a frontend rookie easier. The integration with TailwindCSS is really easy and you can use the full power of TailwindCSS in your Leptos components. Styling and layouting becomes so much easier with TailwindCSS, and you can focus on the logic of your application. A really nice feature of Leptos is the server functions. You can define functions that are executed on the server side and therefor you can hide sensitive data from the client side. This is really useful for example when you want to access a database or an API that requires authentication. For example I am using a server function to forward the contact form data to Formspree which is a service that allows you to receive form submissions via email.


use std::env;
use leptos::*;
use leptos::prelude::*;
use reqwest;

#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct ContactData {
    name: String,
    company: String,
    email: String,
    message: String,
}

#[server]
pub async fn submit_contact_form_to_formspree(contact_form_inputs: ContactData) -> Result<(), ServerFnError> {
    let formspree_url = match env::var("FORM_URL"){
        Ok(url) => url,
        Err(e) => {
            let error_message = format!("Failed to get FORM_URL from environment: {}", e);
            logging::error!("{}", error_message);
            return Err(ServerFnError::new(error_message));
        }
    };

    let client = reqwest::Client::new();

    let res = client.post(formspree_url)
        .header("Accept", "application/json")
        .json(&contact_form_inputs)
        .send()
        .await
        .map_err(|e| ServerFnError::new(e.to_string()))?;

    if res.status().is_success() {
        logging::log!("Form submitted successfully!");
        Ok(())
    } else {
        let error_message = format!("Form submission failed with status: {}", res.status());
        logging::error!("{}", error_message);
        Err(ServerFnError::new(error_message))
    }
}
            
            

Then you can use this function in your Leptos components like this:


#[component]
pub fn Contact() -> impl IntoView { 
    let submit = ServerAction::<crate::app::server::formspree::SubmitContactFormToFormspree>::new();

    let (name, set_name) = signal(String::new());
    let (company, set_company) = signal(String::new());
    let (email, set_email) = signal(String::new());
    let (message, set_message) = signal(String::new());
    let (submission_success, set_submission_success) = signal(false);

    let on_submit = move |_| {
        set_name.set("".to_string());
        set_company.set("".to_string());
        set_email.set("".to_string());
        set_message.set("".to_string());
        set_submission_success.set(true);
    };

    view! {
        <ActionForm action=submit on:submit=on_submit class="space-y-4 max-w-md mx-auto bg-white p-6 rounded-lg shadow">
            <div class="mb-4">
                <label for="name" class="block text-gray-700 font-semibold mb-1">
                    "What's your name?"
                </label>
                <input
                    type="text"
                    id="name"
                    name="contact_form_inputs[name]"
                    placeholder="Enter your name"
                    required
                    class="w-full border border-gray-300 rounded px-3 py-2"
                    prop:value=name
                    on:input=move |ev| {
                        set_name.set(event_target_value(&ev));
                    }
                />
            </div>
            <div class="mb-4">
                <label for="company" class="block text-gray-700 font-semibold mb-1">
                    "What's your company?"
                </label>
                <input
                    type="text"
                    id="company"
                    name="contact_form_inputs[company]"
                    placeholder="Enter your company name"
                    required
                    class="w-full border border-gray-300 rounded px-3 py-2"
                    prop:value=company
                    on:input=move |ev| {
                        set_company.set(event_target_value(&ev));
                    }
                />
            </div>
            <div class="mb-4">
                <label for="email" class="block text-gray-700 font-semibold mb-1">
                    "What's your Email Address?"
                </label>
                <input
                    type="email"
                    id="email"
                    name="contact_form_inputs[email]"
                    placeholder="Enter your email"
                    required
                    class="w-full border border-gray-300 rounded px-3 py-2"
                    prop:value=email
                    on:input=move |ev| {
                        set_email.set(event_target_value(&ev));
                    }
                />
            </div>
            <div class="mb-4">
                <label for="message" class="block text-gray-700 font-semibold mb-1">
                    "Your Message"
                </label>
                <textarea
                    id="message"
                    name="contact_form_inputs[message]"
                    rows="4"
                    placeholder="Write your message here..."
                    required
                    class="w-full border border-gray-300 rounded px-3 py-2"
                    prop:value=message
                    on:input=move |ev| {
                        set_message.set(event_target_value(&ev));
                    }
                ></textarea>
            </div>
            <input type="text" name="_gotcha" style="display:none;" />
            <div>
                <button type="submit" class="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold px-4 py-2 rounded">
                    Send Message
                </button>
            </div>
        </ActionForm>
        <Show when=move || submission_success.get()>
            <div class="mt-6 p-4 bg-emerald-50 rounded text-emerald-800 text-center">
                <h2 class="text-lg font-bold mb-2">"Message Sent!"</h2>
                <p class="mb-2">"Thank you for your message. We'll get back to you soon."</p>
                <button class="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold px-4 py-2 rounded"
                        on:click=move |_| set_submission_success.set(false)>
                    "Okay"
                </button>
            </div>
        </Show>
    }
}
            
            

The Leptos ActionForm is used to submit the form data to the server function. You call the server function when the Form is commited. The inputs of the server function are created as signals and are set when the user types in the input fields. With the name field of the input, you are defining the input arguments of the server function.

To be honest with you, this is a very good example in which I had to learn this kind of coding with Rust and Leptos. Compared to my low level code this style of writing code felt much less intuitive at the beginning, but I really appreciate the power of Leptos now. I see some downsides while developing this style of code, since your IDE wont help you much.

5. Conclusion

Please let me state this again: My main goal was to learn Leptos and hosting my own website on Azure. From the software development point of view I can highly recommend Leptos. Before Leptos I was playing around with different frameworks but they never hooked me. Leptos in combination with TailwindCSS is a very powerful combination. I started to write my first CSR apps which you can easily host on Cloudflare pages for free. During my development I realized that I need more functionalities and so I moved to the SSR approach. SSR apps gives you much more opprtunities but you also have to take care about the hosting. Instead of static files, you basically need to operate you own server now.
Here we are speaking now about the infrastructure point of view and the experience I made with Azure in this kind of setup is not really great. My biggest learning is: You are much more careful how and what you deploy, when your own credit card is on the line. Obviously I decided to containerize the app and use PaaS services like Azure Container Instances and Azure Container Apps. I cannot recommend the Azure Container Instances, since they are not made for continuous running applications. After trying out different approaches, I ended up with Azure Container Apps. The down scaling feature is not only nice in regard of saving costs, but also to reduce your CO2 footprint.
Not running my container full time but only if people want to visit my website ended up in my last problem: The cold start time of the container app. So I decided to run a simple static web page as a loading screen on Cloudflare Pages. This solves the issue of users not getting a response, but also gives me full features of Cloudflare in regard to CDN, certificates or DDOS protection.
All in all, it was quite a journey. I finally can roll out updates fully automated and I am happy with my architecture. I think, it is a bit over the top for the current status of my website, but I am planning other projects and features which will soon justify all this work. I hope you enjoyed this blog post and learned something new. Please feel free to reach out to me if you have any questions or feedback.