Getting back to the EU: from Google Cloud to Self-Hosted EU Infrastructure


In this article I’m going to show the process I followed to migrate some of the services I used from Google Cloud to an European provider, in this case OVH. I won’t use their cloud solution, but their VPS offering instead, in order to have full control over the infrastructure.1

In particular, I will show how I moved out from Cloud SQL to a self hosted instance of PostgreSQL, how I moved the Cloud Run services to a more standard nginx setup, and how reached the same level of CI/CD I had with Google Cloud.

The migrated service is fitsleepinsights.app: a custom dashboard for Fitbit users, with RAG support (gemini-based) for chatting with your data and getting insights about your fitness activities and sleep data. The service is fully open source, and the code is available on GitHub.

The reasons

There are two main reasons why I decided to move the service from Google Cloud to OVH. The first one is purely economical: the costs of the Cloud SQL instance were too high. I was paying too much for the very minimal setup of the instance, which delivered very poor performance.

The second reason is political. It’s my first small step of “getting back to the EU” to reduce dependency on US companies.

The CI/CD pipeline

The CI/CD pipeline is similar to what I had with Google Cloud, but now it’s hosted on GitHub Actions. Instead of deploying to the cloud, I deploy the service on a VPS. The end result is the same: the service is deployed in seconds, but at a much lower cost. Additionally, since we are deploying a Go application that compiles to self-contained binaries, the deployment is very fast and doesn’t require any external dependencies or containerization.

For the sake of completeness, I’m going to show the Github Actions workflow for the Google Cloud and the OVH VPS.

The Google Cloud CI/CD pipeline

The Github Actions workflow is the following:

  • Checkout the code
  • Authenticate to Google Cloud Artifact Registry
  • Build the application inside a Docker container
  • Push the application to the Artifact Registry
  • Deploy the application to the Cloud Run service

This of course requires a cloud side configuration, with a lot of clicks, and regions limitations.

e.g. for automatize the deploy of the cloud run service under the domain fitsleepinsights.app (registered on Google Domains), I had to re-configure the cloud run service in order to be in a region compatible with the domain (see the CLOUD_RUN_REGION variable in the YAML file below).

The cloud run service itself had to be configured by specifying all the parameters related to the cold start, the environment variables, the secrets, the VPC, the service account, etc.

So, the deploy is not the YAML file you see below, but a mix of YAML and clicks on the Google Cloud Console (and a ton of trial and error). Anyway, I leave the YAML file below for the sake of completeness.

name: Build and Deploy to Cloud Run

on:
  push:
    branches: [ main ]

# The secrets have been defined inside the Github settings
# The env vars MUST be defined here to be accessible with env.VAR
env:
  GAR_LOCATION: europe-west6
  PROJECT_ID: train-and-deploy-experiment
  CLOUD_RUN_REGION: europe-west1 # must be 1 to support google domain
  REPOSITORY: fitsleep-actions-repo
  SERVICE: fitsleep-actions-cloudrun-service

jobs:
  deploy:
    permissions:
      contents: 'read'
      id-token: 'write'

    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Google Auth
        id: auth
        uses: 'google-github-actions/auth@v2'
        with:
          token_format: 'access_token'
          workload_identity_provider: '${{ secrets.WIF_PROVIDER }}'
          service_account: '${{ secrets.WIF_SERVICE_ACCOUNT }}'

      - name: Docker Auth
        id: docker-auth
        uses: 'docker/login-action@v3'
        with:
          username: 'oauth2accesstoken'
          password: '${{ steps.auth.outputs.access_token }}'
          registry: '${{ env.GAR_LOCATION }}-docker.pkg.dev'
          
      - name: Build and Push Container
        run: |-
          docker build -t "${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }}" ./
          docker push "${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }}"

      - name: Deploy to Cloud Run
        id: deploy
        uses: google-github-actions/deploy-cloudrun@v2
        with:
          service: ${{ env.SERVICE }}
          region: ${{ env.CLOUD_RUN_REGION }}
          image: ${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }}

      - name: Show Output
        run: echo ${{ steps.deploy.outputs.url }}

The OVH VPS CI/CD pipeline

Unlike cloud services where configuration is done through web interfaces with many clicks, the OVH VPS CI/CD pipeline requires some traditional Linux system administration.

The prerequisites

The prerequisite steps to run on the VPS are straightforward:

  • Install Go
  • Install PostgreSQL
  • Install nginx
  • Install Certbot (Optional, right now I’m still using Cloudflare for automatic HTTPS certificates)

May seem a lot, but in the end it’s just a single command (sudo apt install postgresql nginx certbot), and the configuration of the services at startup. Literally, 5 minutes.

Since this is a service migration, we need to migrate the database as well. Cloud SQL is a managed PostgreSQL instance, so we need to migrate it to a self hosted PostgreSQL instance. Luckily, the migration is very simple, because PostgreSQL is exceptional.

On Cloud SQL we can export the database with a click. Under the hood, it’s a simple pg_dump of the database. Once downloaded, the dump can be imported on a self hosted PostgreSQL instance.

# Follow the instructions in the README to create the user and the database and install pgvector
# after that, import the dump
psql -U $fitsleepinsights -d $fitsleepinsights < fitsleepinsights-dump.sql

After that, we need to create a systemd service that runs the application. I want this to run in the home directory of a non-root user, so I created the following service file (also available in the repository at [email protected]:

# /usr/lib/systemd/system/[email protected]
[Unit]
Description=fitsleepinsights.app
After=postgresql.service

[Service]
Restart=always
Type=simple
User=%I
WorkingDirectory=/home/%I/fitsleepinsights/
ExecStart=/home/%I/go/bin/fitsleepinsights

[Install]
WantedBy=multi-user.target

The %I is the username of the user that will run the service. We suppose that the user exists and has access to the fitsleepinsights directory. The directory is going to be created by the Github Actions workflow (if it doesn’t exist), and the user will own it.

To be able to start and stop the service we need to call systemctl start fitsleepinsights@$USER.service and systemctl stop fitsleepinsights@$USER.service (this is going to be done by the Github Actions workflow).

To make it work, we need to invoke the commands with sudo without being asked for the password. To do that, we need to customize the sudoers file for the user.

# /etc/sudoers.d/$USER
%$USER ALL=(ALL) NOPASSWD:/usr/bin/systemctl start fitsleepinsights@*
%$USER ALL=(ALL) NOPASSWD:/usr/bin/systemctl stop fitsleepinsights@*

To deploy the service from Github actions, we need to create a ssh key and add it to the VPS.

ssh-keygen -t ed25519 -C "fitsleepinsights.app" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys

In this way, the Github Actions workflow can connect to the VPS using the private ssh key that we just created, and that we’ll add to the repository secrets.

The last prerequisite is to configure nginx to proxy the requests to the service. fitsleepinsights.app is a Go application that listens on port 8989, so we need to configure nginx to proxy the requests to that port.

# /etc/nginx/sites-available/fitsleepinsights.app

server {
    server_name fitsleepinsights.app;
    listen 80;

    # turn base_url//resoruce into base_url/resource
    merge_slashes on;

    real_ip_header    X-Real-IP;
    real_ip_recursive on;

    location / {
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
        proxy_pass  http://127.0.0.1:8989;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Request-Start $msec;
    }
}

The very last step on the VPS is to enable the service to start at boot:

sudo systemctl enable fitsleepinsights@$USER.service

The Github Actions workflow

The Github Actions workflow is the following:

  • Checkout the code
  • Build the application
  • Push the application to the VPS
  • Restart the service

We just need to add the private ssh key to the repository secrets (together with the other secrets like the VPS username and the VPS address), and the Github Actions workflow will be able to connect to the VPS and deploy the application.

name: Build and Deploy to OVH SSH server

on:
  push:
    branches: [ main ]

env:
  SSH_HOST: ${{ secrets.SSH_HOST }}
  SSH_USERNAME: ${{ secrets.SSH_USERNAME }}
  SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
  SSH_PORT: ${{ secrets.SSH_PORT }}

jobs:
  deploy:

    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        
      - name: Setup SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ env.SSH_PRIVATE_KEY }}
          
      - name: Build for SSH deployment
        uses: actions/setup-go@v4
        with:
          go-version-file: 'go.mod'
      - name: build
        run: |
           go build -o fitsleepinsights
          
      - name: Deploy to SSH server
        run: |
          # Ensure the remote directory exists
          ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }} ${{ env.SSH_USERNAME }}@${{ env.SSH_HOST }} "mkdir -p ~/fitsleepinsights"

          # Stop the service if it's running
          ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }} ${{ env.SSH_USERNAME }}@${{ env.SSH_HOST }} "sudo systemctl stop fitsleepinsights@${{ env.SSH_USERNAME }}.service || true"

          # Deploy the application: just the binary
          cat fitsleepinsights | ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }} ${{ env.SSH_USERNAME }}@${{ env.SSH_HOST }} "cat > ~/go/bin/fitsleepinsights"
          
          # Deploy the application static files and templates (needed for runtime)
          rsync -avz -e "ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }}" \
            --exclude='.git/' \
            --exclude='.github/' \
            ./ ${{ env.SSH_USERNAME }}@${{ env.SSH_HOST }}:fitsleepinsights/

          # Start the service
          ssh -o StrictHostKeyChecking=no -p ${{ env.SSH_PORT }} ${{ env.SSH_USERNAME }}@${{ env.SSH_HOST }} "sudo systemctl start fitsleepinsights@${{ env.SSH_USERNAME }}.service"

The workflow is live on the repository, so you can see it here.

The CI is working, and the service is deployed on the VPS in seconds. You can see the actions running here.

Moving out from Google Cloud: the cost savings

The configuration of the Cloud SQL was the minimal one, with the least amount of resources: 1 vCPU, 2GB of RAM, 100GB of storage. No high availability, and deployed on a single region.

With the configuration, the cost was around 50€/month - only for the database!

Cloud SQL cost

The performance were poor, and the cost was too high for the resources used.

With the VPS instead, I have the freedom to do whatever I want. I deployed the database together with the service, with a more powerful configuration: 4 vCPU, 4GB of RAM, 80GB of storage.

VPS cost

The cost is around 10€/month for the database and the service together.

The performance are great and the cost is much lower than the Cloud SQL.

Differently from a pure Cloud solution, I have no database lag at all, no problems of cold start, no issues at all. Of course the scalability is not the same, but even with the cloud solution I would have been constrained by the fixed resources allocated to the Cloud SQL instance, making the scalability a problem anyway.

Self-hosting vs Cloud: The trade-offs

It’s important to acknowledge that this self-hosted solution doesn’t offer the same theoretical scalability as a cloud platform. However, in practice, even the Google Cloud solution I was using had significant scalability limitations. The Cloud SQL instance with its fixed resources would have been a bottleneck regardless, requiring manual intervention and additional costs to scale up.

What surprised me most was the performance difference. The default VPS configuration significantly outperforms the basic Cloud SQL setup I was using. Database queries that took seconds on Cloud SQL now complete in milliseconds. The elimination of cold starts for the application has also improved the user experience dramatically.

For small to medium-sized applications with predictable traffic patterns, a well-configured VPS can provide better performance, more control, and substantial cost savings compared to cloud solutions. The cloud’s theoretical advantages in scalability and managed services often come with practical disadvantages: higher costs, performance compromises at entry-level tiers, and less control over your infrastructure.

In the end, the right choice depends on your specific needs, but don’t assume the cloud is always superior. Sometimes, a return to more traditional hosting approaches can yield better results for your particular use case.

The future

This migration represents just the first step in my journey to “get back to the EU.” My roadmap includes several additional phases:

  1. Domain migration: Moving from Google Domains to a European domain registrar
  2. DNS migration: Switching to a European DNS provider
  3. Analytics: Replacing Google Analytics with EU-hosted alternatives like Matomo

There are certain services that remain challenging to migrate, particularly the Gemini RAG used for Fitbit data analysis. For now, I’ll keep these on Google Cloud while monitoring the development of European AI alternatives.

This incremental approach allows me to balance practical considerations with the goal of digital sovereignty. Each migration step reduces dependency on non-EU providers while maintaining service quality for users.

  1. I’ve chosen the VPS because I already had a VPS on OVH, so I didn’t need to spend money on a new one. 

Don't you want to miss the next article? Do you want to be kept updated?
Subscribe to the newsletter!

Related Posts

Using AI for Coding: My Journey with Cline and Large Language Models

How I leveraged AI tools like Cline to enhance the UI/UX of a website and streamline backend tasks. From redesigning pages and translating content to navigating the benefits and challenges of AI-assisted development, this blog post highlights the potential of using large language models to boost productivity while sharing key lessons learned.

Fixing the code signing and notarization issues of Unreal Engine (5.3+) projects

Starting from Unreal Engine 5.3, Epic Games added support for the so-called modern Xcode workflow. This workflow allows the Unreal Build Tool (UBT) to be more consistent with the standard Xcode app projects, and to be compliant with the Apple requirements for distributing applications... In theory! 😅 In practice this workflow is flawed: both the code signing and the framework supports are not correctly implemented, making the creation of working apps and their distribution impossible. In this article, we'll go through the problems faced during the packaging, code signing, and notarization of an Unreal Engine application on macOS and end up with the step-by-step process to solve them all.

The (Hidden?) Costs of Vertex AI Resource Pools: A Cautionary Tale

In the article "Custom model training & deployment on Google Cloud using Vertex AI in Go" we explored how to leverage Go to create a resource pool and train a machine learning model using Vertex AI's allocated resources. While this approach offers flexibility, there's a crucial aspect to consider: the cost implications of resource pools. This article details my experience with a sudden price increase in Vertex AI and the hidden culprit – a seemingly innocuous resource pool.

Building a RAG for tabular data in Go with PostgreSQL & Gemini

In this article we explore how to combine a large language model (LLM) with a relational database to allow users to ask questions about their data in a natural way. It demonstrates a Retrieval-Augmented Generation (RAG) system built with Go that utilizes PostgreSQL and pgvector for data storage and retrieval. The provided code showcases the core functionalities. This is an overview of how the "chat with your data" feature of fitsleepinsights.app is being developed.

Using Gemini in a Go application: limits and details

This article explores using Gemini within Go applications via Vertex AI. We'll delve into the limitations encountered, including the model's context window size and regional restrictions. We'll also explore various methods for feeding data to Gemini, highlighting the challenges faced due to these limitations. Finally, we'll briefly introduce RAG (Retrieval-Augmented Generation) as a potential solution, but leave its implementation details for future exploration.

Custom model training & deployment on Google Cloud using Vertex AI in Go

This article shows a different approach to solving the same problem presented in the article AutoML pipeline for tabular data on VertexAI in Go. This time, instead of relying on AutoML we will define the model and the training job ourselves. This is a more advanced usage that allows the experienced machine learning practitioner to have full control on the pipeline from the model definition to the hardware to use for training and deploying. At the end of the article, we will also see how to use the deployed model. All of this, in Go and with the help of Python and Docker for the custom training job definition.

Integrating third-party libraries as Unreal Engine plugins: solving the ABI compatibility issues on Linux when the source code is available

In this article, we will discuss the challenges and potential issues that may arise during the integration process of a third-party library when the source code is available. It will provide guidance on how to handle the compilation and linking of the third-party library, manage dependencies, and resolve compatibility issues. We'll realize a plugin for redis plus plus as a real use case scenario, and we'll see how tough can it be to correctly compile the library for Unreal Engine - we'll solve every problem step by step.

AutoML pipeline for tabular data on VertexAI in Go

In this article, we delve into the development and deployment of tabular models using VertexAI and AutoML with Go, showcasing the actual Go code and sharing insights gained through trial & error and extensive Google research to overcome documentation limitations.

Advent of Code 2022 in pure TensorFlow - Day 12

Solving problem 12 of the AoC 2022 in pure TensorFlow is a great exercise in graph theory and more specifically in using the Breadth-First Search (BFS) algorithm. This problem requires working with a grid of characters representing a graph, and the BFS algorithm allows us to traverse the graph in the most efficient way to solve the problem.

Advent of Code 2022 in pure TensorFlow - Day 11

In this article, we'll show how to solve problem 11 from the Advent of Code 2022 (AoC 2022) using TensorFlow. We'll first introduce the problem and then provide a detailed explanation of our TensorFlow solution. The problem at hand revolves around the interactions of multiple monkeys inspecting items, making decisions based on their worry levels, and following a set of rules.