Migrating a NestJS Backend from Heroku to Scaleway. Part 1/3: Infrastructure & Foundation
A step-by-step walkthrough of migrating a production NestJS backend from Heroku to Scaleway, covering VPS setup, server hardening, Docker configuration, and PostgreSQL data migration.
1. Why I Left Heroku
My stack had been running on Heroku for years: a NestJS backend, a PostgreSQL database, and an Elasticsearch cluster on Elastic Cloud. It worked, until three problems became impossible to ignore.
- Cost. Heroku killed its free tier in 2022. Paid dynos scale quickly once you need decent RAM or a production-grade PostgreSQL. For a SaaS stack, you hit VPS equivalent pricing without the control that should come with it.
- Visibility. No SSH access, no native Docker logs, minimal monitoring, no way to tune PostgreSQL. Every production performance issue meant debugging blind.
- Performance. Heroku dynos are shared by nature. My Elasticsearch cluster was already running on Elastic Cloud, centralizing the rest of the infrastructure made sense.
Why Scaleway Over AWS, OVH, or Hetzner?
Scaleway is a French cloud provider, which simplifies GDPR compliance, data stays in Europe under French law. For regulated industries, they offer HDS (Health Data Hosting) certification and are pursuing SecNumCloud qualification from ANSSI, France's highest cloud security standard. Not a hard requirement for this project, but a real advantage for future-proofing toward healthcare, finance, or public sector use cases.
Pricing is competitive against AWS and GCP on CPU instances. Their managed services ecosystem: PostgreSQL, Object Storage, Secret Manager, Cockpit. Covers most needs without juggling ten different providers.
Hetzner would have been a serious alternative on pure price. But the lack of a comparable managed PostgreSQL offering and fewer integrated services tipped the balance toward Scaleway.
Target architecture: hardened Scaleway VPS, managed PostgreSQL with high availability, CI/CD via GitHub Actions, zero-downtime deployments with Traefik. This first part covers the foundations: instance selection, server hardening, Docker setup, and PostgreSQL migration.
2. Choosing the Right Scaleway Instance
Scaleway offers several instance families. Here's how I decided:
- DEV1-S: 2 vCPUs, 2 GB RAM, ~€7/mo → fine for prototyping, not production
- PRO2-S: 2 vCPUs, 8 GB RAM, ~€40/mo → tight with Docker containers in the mix
- PRO2-XS: 4 vCPUs, 16 GB RAM, ~€80/mo → ✅ right balance for real SaaS traffic
I went with PRO2-XS configured with:
- Ubuntu 24.04 LTS: long-term support, excellent Docker ecosystem
- 40 GB block storage at 5K IOPS: for logs, Docker volumes, workspace
- Flexible IPv4: reassignable fixed IP, useful if you swap instances later
The ~€80/month may look high, but managed PostgreSQL (section 3) is where the real budget goes.
Server Hardening
First thing after getting root credentials: never use root again. I created a dedicated deploy user, disabled password authentication, and installed fail2ban, brute-force SSH attacks start within minutes of exposing a server.
# Create non-root deploy user
adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
# Disable password auth and root login
sed -i 's/#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl reload ssh
# UFW + fail2ban + automatic security patches
apt-get install -y fail2ban unattended-upgrades
systemctl enable --now fail2banunattended-upgrades is frequently overlooked but critical, it automatically applies OS security patches without manual intervention.
3. Docker With a Production-Ready Config
Installation is trivial with the official script. The configuration that follows is where most setups go wrong.
curl -fsSL https://get.docker.com | sudo sh
usermod -aG docker deployLog Rotation
Docker doesn't limit container log size by default. On an app running for weeks, JSON log files can consume tens of gigabytes and fill your disk. I configure this globally in /etc/docker/daemon.json:
echo '{"log-driver":"json-file","log-opts":{"max-size":"50m","max-file":"3"}}' \
| sudo tee /etc/docker/daemon.json
sudo systemctl restart dockerGotcha: malformed JSON indaemon.jsonprevents Docker from starting. Always validate before restarting:python3 -m json.tool /etc/docker/daemon.json
Swap Configuration
With 16 GB RAM, swap might seem unnecessary. In practice, it protects against unexpected memory spikes, an OOM-killer restart in production is far worse than occasional swap access.
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabThe last line persists swap across reboots via /etc/fstab.
4. Managed PostgreSQL on Scaleway
Why Managed Over Self-Hosted?
Running PostgreSQL in a Docker container on the same VPS is simpler to deploy, but creates real production problems:
- No high availability — VPS restart takes down the database
- Manual backup management
- Manual PostgreSQL upgrades
- Shared resources with the backend
For a production SaaS, managed PostgreSQL pays for itself.
Instance Selection
Starting point: Heroku PostgreSQL Standard 0 (1.09 GB data, PostgreSQL 16).
Selected: DB-PRO2-XXS (~€125/month)
- 2 vCPUs, 8 GB RAM
- 2-node High Availability: automatic failover if primary node goes down
- PostgreSQL 17
- Native encryption at rest
- Configurable automated snapshots
Migrating Data from Heroku
Migration in two steps: dump from Heroku, restore to Scaleway.
# Step 1: backup from Heroku
heroku pg:backups:capture --app my-app
heroku pg:backups:download --app my-app -o heroku-latest.dump
# Step 2: restore to Scaleway
PGPASSWORD='...' PGSSLMODE=require pg_restore \
--no-owner --no-acl --clean --if-exists \
-h 51.159.x.x -p 12453 -U admin -d rdb \
heroku-latest.dump--no-owner and --no-acl are essential: Heroku dumps reference Heroku-specific roles that don't exist on Scaleway and will cause failures without these flags.
Expected Errors (Safe to Ignore)
The restore will output errors. Most are benign:
must be owner of schema public→ Heroku uses a restricted user for dumps, ownership errors are expectedschema "heroku_ext" does not exist→ Heroku-internal extension, not present on Scaleway
These don't affect data integrity.
Verifying the Migration
Before cutting over anything, I compare row counts between both databases:
for tbl in users tokens categories; do
H=$(psql "$HEROKU_DB" -tAc "SELECT COUNT(*) FROM \"$tbl\"")
S=$(psql "$SCW_DB" -tAc "SELECT COUNT(*) FROM \"$tbl\"")
echo "$tbl — heroku=$H scaleway=$S"
doneMatching counts means the migration is reliable.
Network Access
Scaleway defaults to allowing 0.0.0.0/0 on the database. Never leave this in production. I immediately restricted access to legitimate IPs only:
- Production VPS IP (
163.172.x.x/32) - My dev machine IP for manual access (temporary if possible)
Where Things Stand
At the end of this part, the base infrastructure is operational:
- ✅ Hardened PRO2-XS VPS, non-root
deployuser, fail2ban active - ✅ Docker configured with log rotation and swap
- ✅ Managed HA PostgreSQL with migrated and verified data
Next up: building the CI/CD pipeline with GitHub Actions and automating deployments to this VPS.
→ Part 2/3: CI/CD & HTTPS: GitHub Actions, Let's Encrypt, deployment structure (coming soon)
This article is part of a series documenting a production infrastructure migration from Heroku to Scaleway. Stack: NestJS, PostgreSQL, Elasticsearch, Docker, GitHub Actions.