Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Table of Contents:
| **[Deploy Memos app](containers/memos-terraform/)** <br/> A journaling application with its database deployed with Terraform | Terraform | [Terraform] |
| **[Metabase on VPC](containers/vpc-metabase/README.md)** <br/> A Metabase instance running in a private network with a PostgreSQL database. | N/A | [Terraform] |
| **[MongoDB® on VPC](containers/vpc-mongodb/README.md)** <br/> A MongoDB® instance running in a private network with a sample application connecting to it. | TypeScript on Deno | [Terraform] |
| **[pg-connection-retry](containers/pg-connection-retry/README.md)** <br/> A Node.js container that retries its VPC connection to a Postgres database on startup. | Node.js | [Terraform] |

### ⚙️ Jobs

Expand Down
5 changes: 5 additions & 0 deletions containers/pg-connection-retry/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.git
*.tfstate
*.tfstate.*
.terraform/
47 changes: 47 additions & 0 deletions containers/pg-connection-retry/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Node.js
node_modules/

# Local .terraform directories
.terraform/

# .tfstate files
*.tfstate
*.tfstate.*

# Crash log files
crash.log
crash.*.log

# Exclude all .tfvars files, which are likely to contain sensitive data, such as
# password, private keys, and other secrets. These should not be part of version
# control as they are data points which are potentially sensitive and subject
# to change depending on the environment.
*.tfvars
*.tfvars.json

# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json

# Ignore transient lock info files created by terraform apply
.terraform.tfstate.lock.info

# Include override files you do wish to add to version control using negated pattern
# !example_override.tf

# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*

# Ignore CLI configuration files
.terraformrc
terraform.rc

# Optional: ignore graph output files generated by `terraform graph`
# *.dot

# Optional: ignore plan files saved before destroying Terraform configuration
# Uncomment the line below if you want to ignore planout files.
# planout
11 changes: 11 additions & 0 deletions containers/pg-connection-retry/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM node:22-slim

WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm install --omit=dev

COPY . .

EXPOSE 8080
CMD ["node", "index.js"]
100 changes: 100 additions & 0 deletions containers/pg-connection-retry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# pg-connection-retry

A minimal Node.js example demonstrating how to make a Scaleway Serverless Container resilient to VPC connection delays when it starts.

## Context

Sometimes, when a Serverless Container is reloaded, it spawns **before** its VPC connection is fully ready. This is a known issue on the VPC side. Until it is fixed, applications need to **retry** their database connection on startup rather than crashing immediately.

This example shows a simple Express app that:

1. Tries to connect to a Postgres database on startup.
2. Retries every 5 seconds (up to 12 times) if the connection fails.
3. Only starts the HTTP server once a connection is established.

## How it works

- The app uses the [`pg`](https://node-postgres.com/) library and a standard libpq connection (via `PGHOST`, `PGPORT`, `PGDATABASE`, `PGUSER`, `PGPASSWORD` environment variables — no hardcoded credentials).
- `waitForDatabase()` runs a `SELECT 1` query in a retry loop before starting the Express server.
- If all retries are exhausted, the process exits with a non-zero code so the container platform can restart it.

## Files

| File | Description |
|----------------|--------------------------------------------------------------------------------|
| `index.js` | The Node.js application with retry logic |
| `package.json` | Dependencies (`express`, `pg`) |
| `Dockerfile` | Builds the container image |
| `main.tf` | Terraform config: RDB instance, VPC, private network, and Serverless Container |
| `variables.tf` | Terraform input variables |
| `providers.tf` | Terraform provider configuration |
| `outputs.tf` | Terraform outputs (container endpoint) |

## Prerequisites

- A Scaleway account
- [Terraform](https://developer.hashicorp.com/terraform/install) or [OpenTofu](https://opentofu.org/) installed
- [Docker](https://docs.docker.com/get-docker/) installed
- [Scaleway CLI](https://www.scaleway.com/en/docs/scaleway-cli/) installed and authenticated

## Deploy

### 1. Build and push the container image

Log in to the Scaleway Container Registry:

```bash
scw registry login
```

Build and push the image:

```bash
docker build -t rg.fr-par.scw.cloud/examples/pg-connection-retry:latest .
docker push rg.fr-par.scw.cloud/examples/pg-connection-retry:latest
```

### 2. Deploy with Terraform

Set the required variables (e.g. via environment variables):

```bash
export TF_VAR_db_admin_password="your-secure-admin-password"
export TF_VAR_db_password="your-secure-app-password"
export TF_VAR_registry_image="rg.fr-par.scw.cloud/examples/pg-connection-retry:latest"
```

Apply the Terraform configuration:

```bash
tofu init
tofu apply
```

### 3. Access the container

After the deployment, you can find the container endpoint in the output:

```bash
tofu output container_public_endpoint
```

You can test the connection:

```bash
CONTAINER_URL=$(tofu output -raw container_public_endpoint)

# Health check
curl $CONTAINER_URL/

# Database ping
curl $CONTAINER_URL/ping
```

## Cleaning Up

Destroy the Terraform-managed infrastructure when you're done:

```bash
tofu destroy
```
78 changes: 78 additions & 0 deletions containers/pg-connection-retry/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const express = require("express");
const { Pool } = require("pg");

const app = express();
const port = process.env.PORT || 8080;

// Retry configuration
const RETRY_INTERVAL_MS = 5000; // Wait between retries
const MAX_RETRIES = 12; // Give up after ~1 minute

// pg reads standard libpq environment variables (PGHOST, PGPORT, etc.)
// which are injected by the Scaleway Container.
const pool = new Pool();

pool.on("error", (err) => {
console.error("Unexpected error on idle client:", err.message);
});

// SQLSTATE error classes that indicate the connection is gone:
// 08xxx – Connection Exception
// 53xxx – Insufficient Resources
// 57xxx – Operator Intervention
function isConnectionError(err) {
return err.code && /^(08|53|57)/.test(err.code);
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// Try to establish a connection to the database, retrying until it succeeds
// or MAX_RETRIES is reached. This handles the case where the VPC connection
// is not fully ready when the container starts.
async function waitForDatabase() {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await pool.query("SELECT 1");
console.log("Database connection established.");
return true;
} catch (err) {
console.error(
`Connection attempt ${attempt}/${MAX_RETRIES} failed: ${err.message}`
);
if (attempt < MAX_RETRIES) {
await sleep(RETRY_INTERVAL_MS);
}
}
}
return false;
}

app.get("/", (req, res) => {
res.json({ status: true });
});

app.get("/ping", async (req, res) => {
try {
await pool.query("SELECT 1");
res.json({ status: true });
} catch (err) {
console.error("Ping failed:", err.message);
res.status(500).json({ status: false, error: err.message });
}
});

async function main() {
const connected = await waitForDatabase();
if (!connected) {
console.error("Could not connect to the database, exiting.");
process.exit(1);
}

app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
}

main();
100 changes: 100 additions & 0 deletions containers/pg-connection-retry/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
locals {
name = "pg-connection-retry"
db_postgres_version = "16"
base_tags = ["pg-connection-retry", "vpc"]
}

resource "scaleway_vpc" "main" {
name = local.name
}

resource "scaleway_vpc_private_network" "main" {
name = local.name
vpc_id = scaleway_vpc.main.id
}

resource "scaleway_rdb_instance" "main" {
name = "db-${local.name}"
tags = concat(local.base_tags, ["pg-${local.db_postgres_version}"])

node_type = "db-play2-nano"

is_ha_cluster = false
private_network {
pn_id = scaleway_vpc_private_network.main.id
enable_ipam = true
}

encryption_at_rest = true
volume_size_in_gb = 10
volume_type = "sbs_5k"

engine = "PostgreSQL-${local.db_postgres_version}"

user_name = var.db_admin_username
password = var.db_admin_password
}

resource "scaleway_rdb_database" "main" {
instance_id = scaleway_rdb_instance.main.id
name = local.name
}

resource "scaleway_rdb_user" "main" {
instance_id = scaleway_rdb_instance.main.id

name = var.db_username
password = var.db_password
}

resource "scaleway_rdb_privilege" "main" {
instance_id = scaleway_rdb_instance.main.id
user_name = scaleway_rdb_user.main.name
database_name = scaleway_rdb_database.main.name
permission = "all"
}

resource "scaleway_container_namespace" "main" {
name = local.name
description = "Namespace for the pg-connection-retry container"
tags = local.base_tags
}

locals {
db_endpoint = scaleway_rdb_instance.main.private_network[0]
rdb_instance_id = split("/", scaleway_rdb_instance.main.id)[1] # To remove the `<region>/` prefix
}

resource "scaleway_container" "main" {
name = local.name
description = "Node.js container that retries its VPC connection to a Postgres DB"
tags = local.base_tags

namespace_id = scaleway_container_namespace.main.id
image = var.registry_image

private_network_id = scaleway_vpc_private_network.main.id

cpu_limit = 1000
memory_limit_bytes = 1024 * 1024 * 1024 # 1 GB
sandbox = "v1"

https_connections_only = true
port = 8080

max_scale = 1 # No real need to have more than one instance running

environment_variables = {
# Within a private network, we can refer to resources using their internal hostname.
# The format is `<resource_id>.<private_network_name>.internal`.
PGHOST = "${local.rdb_instance_id}.${scaleway_vpc_private_network.main.name}.internal"
PGPORT = local.db_endpoint.port

PGDATABASE = scaleway_rdb_database.main.name
PGUSER = scaleway_rdb_user.main.name # Referencing the user directly to create a Terraform dependency
}

secret_environment_variables = {
PGPASSWORD = var.db_password
}
}
4 changes: 4 additions & 0 deletions containers/pg-connection-retry/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "container_public_endpoint" {
description = "Public HTTPS endpoint of the deployed container"
value = "https://${scaleway_container.main.domain_name}"
}
Loading
Loading