# Migrate from a VPS or your own server

This page moves an application running on your own server (a VPS, a dedicated machine, an office server) to Bastide: the process becomes a container, the PostgreSQL database moves into the project's backend, local files go to object storage, then the domain switches without downtime. There is no automatic importer for this case: each step is carried out with ordinary tools.

## Take stock

Before you start, write down what the server really does:

- the running processes (the web service, workers, a `crontab`), their start command and the port they listen on;
- the database, its version and its extensions;
- the files the application writes to disk (uploads, caches, exports);
- the environment variables and the configuration files that carry secrets;
- the domains, and who manages their DNS.

What does not fit in this list (a home-made daemon, a TCP port other than HTTP, SSH access to the machines) has no equivalent on Bastide: read the « What does not carry over » section before going further.

## 1. Containerise the application

Bastide builds three kinds of repositories, in this order: a `Dockerfile` at the root, a recognised framework (see [Frameworks](frameworks.md)), or failing that a fallback that detects the language by itself. For an ordinary Node, Python or Go application, do not provide a `Dockerfile`: the platform composes the image. Provide one if your server installs system packages or runs a particular command.

The runtime contract the image must honour:

- **The port.** The application listens on `0.0.0.0` and on the port given by the `PORT` variable, set by the platform. An image composed by the platform listens on 3000 (8080 for a static site). With your own `Dockerfile`, the port is 3000 unless the repository declares it: an `EXPOSE` is not enough, declare `port` in `glowsoft.json`.
- **Start-up.** The image declares `ENTRYPOINT` or `CMD`. The application answers the start-up probe within the allotted time; the probe path and that time are declared in `glowsoft.json` (`probe.path`, `probe.startupBudgetSeconds`).
- **Shutdown.** The container receives `SIGTERM`, then its in-flight requests have 30 seconds to finish. `STOPSIGNAL` only accepts named signals from the served list.
- **Disk.** The image's file system is read-only. Only `/tmp` is writable, bounded by the tier, and it is lost on restart. `VOLUME` is refused: nothing that must last lives on the container's disk.
- **Architecture.** The image targets `linux/amd64`.
- **Sources.** Base images come from an allowed registry (docker.io, ghcr.io, gcr.io, mcr.microsoft.com, quay.io, public.ecr.aws, registry.k8s.io); `apt` and `apk` only reach the official Alpine, Debian and Ubuntu repositories. The `# syntax=` directive, `ADD` from a remote address and privileged `RUN` options are refused.

A minimal `Dockerfile` for a Node application that ran under `pm2` or `systemd`:

```
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]
```

A background process (a job queue) becomes a `worker` service declared in `glowsoft.json`, within the tier's service limit; this split is only served when the platform composes the Node image itself. A repository that provides its own `Dockerfile` is served as a single service. The `crontab` lines become scheduled tasks (see [Scheduled tasks](cron.md)).

Each build refusal names its cause, where to fix it and the next action; the list of frequent causes is in [Troubleshooting](troubleshooting.md).

## 2. Create the project and its backend

Create the project with its backend, from **Nouveau projet** (New project) or with the `project_create_with_backend` tool, giving the repository address. The backend carries a PostgreSQL 17 database. The platform sets two variables on every environment by default:

- `DATABASE_URL`, the pooler's transaction mode, for the application;
- `DIRECT_URL`, the session mode, for migration tools that hold a session lock (Prisma, for instance).

If you set `DATABASE_URL` yourself (to keep a database elsewhere during the transition), the platform leaves it alone.

## 3. Move the PostgreSQL database

A project's database is not reachable from outside: only the platform's pooler leads to it, and direct connections from an administration tool are not open yet. So `pg_restore` cannot run against Bastide from your machine. Data goes through SQL, which the platform runs for you.

On the server, export the database in custom format, then extract the schema as SQL:

```
pg_dump --format=custom --no-owner --no-privileges --file=base.dump "$SOURCE_DATABASE_URL"
pg_restore --schema-only --no-owner --no-privileges --file=schema.sql base.dump
```

Read `schema.sql` before applying it:

- Extensions must be allowed by the tier: 20 in Discovery and 50 in Standard, from an allow-list; any from Pro upwards.
- The API roles carry the database name as a prefix: a `grant … to authenticated` written as is gets refused (`LIM-11`). Remove the `grant` statements aimed at your server's roles.
- A table is only exposed to the REST API if it has row-level security and at least one policy.

Apply the schema with `database.migrate` (up to 1,000,000 characters per call): it is first rehearsed on a shadow database, and a destructive effect is refused.

```
bastide database migrate --project-id <prj_…> --sql "$(cat schema.sql)"
```

Then load the data, table by table, as `INSERT` statements:

```
pg_dump --data-only --inserts --rows-per-insert=200 --table=public.clients --file=clients.sql "$SOURCE_DATABASE_URL"
```

Each batch goes through `database.sql.write`, in two steps: the first call runs, measures the effect, rolls it back and returns a nonce; the second, with `--confirmation-nonce`, commits if the effect is the same. A call carries at most 100,000 characters: split large files. This back-and-forth is what an agent does well, with the `database_sql_write` tool; a person reads it back in the audit log.

This route suits a database of a few hundred megabytes. For a larger one, write to support before you start.

Finally, check each table's row count on both sides, with `database.sql.read` or the **Éditeur SQL** (SQL editor).

## 4. Move files to storage

The files the application wrote to disk go into a bucket of the project's object storage. The S3-compatible endpoint (`/storage/v1/s3`) is not served (`LIM-07`): an `aws s3` client or `rclone` in S3 mode does not work. Storage is served through the `storage-js` API, under `/storage/v1/bucket` and `/storage/v1/object`.

A copy script, run from the server with a secret key of the project (`sb_secret_…`, created with `backend.key.create`):

```
import { createClient } from "@supabase/supabase-js";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";

const bastide = createClient(process.env.BASTIDE_URL, process.env.BASTIDE_SECRET_KEY);
await bastide.storage.createBucket("uploads", { public: false });
for (const name of await readdir("/var/www/app/uploads")) {
  const bytes = await readFile(join("/var/www/app/uploads", name));
  const { error } = await bastide.storage.from("uploads").upload(name, bytes);
  if (error) console.error(name, error.message);
}
```

If a file exceeds the tier's maximum size (50 MB in Discovery, 5 GB in Standard, 50 GB in Pro), the upload is refused, naming it. Then adapt the application's code to read and write the bucket rather than the disk. See [Storage](storage.md).

## 5. Carry over the variables

Open **Variables** and use « Importer un .env » (import a .env), pasting the server's `.env` file, or write each variable with the `env_write_production` tool. Values are sensitive by default: they can no longer be read back in clear. If the repository carries a `.env.example`, a build is refused while a variable it declares is missing (`variables_requises_manquantes`); a variable set empty is enough if the application copes with it. See [Environments](environments.md).

## 6. Deploy and test

Deploy with `deployment_create`, follow the job with `task_read`, read the log. The application then runs on its deployment address, next to the server that still serves production. Replay your journeys: sign-in, read, write, upload.

## 7. Switch without downtime

There is no continuous replication from your server: whatever is written at the source after the copy does not follow. The switch therefore happens in two stages.

1. Attach the domain (**Domaines**, or `domain.add`) and set the TXT proof, valid for seven days. Also set the CNAME from the `acmeChallenge` field: the certificate is issued before the switch.
2. Lower the time to live (TTL) of the current DNS records a few hours ahead.
3. At the chosen time, put the server's application in read-only or maintenance mode.
4. Replay the copy of the data and files written since the first copy, then compare the counts.
5. Replace the DNS records with those returned by `domain.add`: A and AAAA for an apex, CNAME for a subdomain.
6. Keep the server read-only for a few days, until old DNS caches expire, then stop it.

If something goes wrong before step 5, nothing changed for your visitors: lift the server's maintenance mode.

## How long to plan for

- Containerising: from a few minutes for an application the platform recognises, to a day for a server that installed a lot by hand.
- A build takes, at the measured 95th percentile, from a few seconds to a little over two minutes depending on the framework.
- The database: the export time, then the `database.sql.write` round trips, in proportion to the volume.
- The switch window: the time to replay recent writes, plus DNS propagation, a few minutes if the time to live was lowered.

## What does not carry over

- A persistent disk attached to the container: files go to object storage, data to the database.
- An inbound port other than HTTP and HTTPS: traffic goes through the platform's edge, and no port is open on the runtime servers.
- SSH access to the container, or a command run by hand on the machine.
- A `linux/arm64` image.
- Outbound SMTP from the free project; for emails, use the backend's transactional emails (see [Emails](emails.md)).

The migration offer (50 % for 3 months) also applies to a project coming from a server, on presentation of an invoice from the previous host less than 3 months old; see [Billing](billing.md).

See also: [Migrate from Vercel](from-vercel.md), [Migrate from Supabase](from-supabase.md), [Database](database.md), [Domains](domains.md), [Leave Bastide](leave.md).
