Guide for migrating your Replit app to EU region for hosting

I like how easy it is to develop and publish with Replit but my issue has been that there is no option to host outside US. Hopefully Replit will provide more regions in the future
After chatting with agent for a while I found out relatively easy way to migrate app to almost any region (or use following setup when building with Agent). Im from EU and I want to host my apps on EU zone for easier GDPR compliance.

This guide is for React apps and requires you create accounts for few service providers.

It works best for new apps or if you dont yet have a lot of data in Replit database or AppStorage otherwise you need to migrate users/data.
For new apps you can guide Agent to write the components with modifications, so you dont have to change them afterwards.

This way I have been able to develop in Replit with Agent without being restricted to US hosting only.

Overview: Components to Migrate

Following app components will be migrated:

  1. Authentication (Replit Auth → Auth0)
  2. Database (Replit Database → Neon PostgreSQL)
  3. Object Storage (Replit Object Storage → Google Cloud Storage)
  4. Application Hosting (Replit VM → Fly.io)

Step 1: Migrate Authentication (Replit Auth → Auth0)

Replit Auth uses OIDC (OpenID Connect), making Auth0 an excellent drop-in replacement with EU hosting options.
You can also use your own domain so login flow stays inside your domain URL and customize login screen for authentication

1.1 Set Up Auth0

  1. Create Auth0 Account: Sign up at Auth0.com
  2. Select EU Region: Choose a European region (e.g., eu.auth0.com)
  3. Create Application:
    • Go to Applications → Create Application
    • Choose “Regular Web Applications”
    • Select “Express” as the technology

1.2 Configure Auth0 Application

  1. Set Allowed Callback URLs:

    http://localhost:5000/callback,
    https://your-app-name.replit.dev/api/callback,
    https://your-production-domain.com/api/callback
    
  2. Set Allowed Logout URLs:

    http://localhost:5000,
    https://your-app-name.replit.dev,
    https://your-production-domain.com
    

1.3 Update Your Code

Changing auth provider requires minimal code changes, mostly ENV var changes. Example for replacing your Replit Auth implementation in replitAuth.ts with Auth0:

import * as client from "openid-client";
import { Strategy, type VerifyFunction } from "openid-client/passport";

import passport from "passport";
import session from "express-session";
import type { Express, RequestHandler } from "express";
import memoize from "memoizee";
import connectPg from "connect-pg-simple";
import { storage } from "./storage";

if (!process.env.AUTH0_DOMAIN) {
  throw new Error("Environment variable AUTH0_DOMAIN not provided");
}

if (!process.env.AUTH0_CLIENT_ID) {
  throw new Error("Environment variable AUTH0_CLIENT_ID not provided");
}

if (!process.env.AUTH0_CLIENT_SECRET) {
  throw new Error("Environment variable AUTH0_CLIENT_SECRET not provided");
}

if (!process.env.SESSION_SECRET) {
  throw new Error("Environment variable SESSION_SECRET not provided");
}

const getOidcConfig = memoize(
  async () => {
    return await client.discovery(
      new URL(`https://${process.env.AUTH0_DOMAIN}`),
      process.env.AUTH0_CLIENT_ID!,
      process.env.AUTH0_CLIENT_SECRET!
    );
  },
  { maxAge: 3600 * 1000 }
);

export function getSession() {
  const sessionTtl = 7 * 24 * 60 * 60 * 1000; // 1 week
  const pgStore = connectPg(session);
  const sessionStore = new pgStore({
    conString: process.env.DATABASE_URL,
    createTableIfMissing: true,
    ttl: sessionTtl,
    tableName: "sessions",
  });
  return session({
    secret: process.env.SESSION_SECRET!,
    store: sessionStore,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: sessionTtl,
    },
  });
}

function updateUserSession(
  user: any,
  tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers
) {
  user.claims = tokens.claims();
  user.access_token = tokens.access_token;
  user.refresh_token = tokens.refresh_token;
  user.expires_at = user.claims?.exp;
}

async function upsertUser(
  claims: any,
) {
  await storage.upsertUser({
    id: claims["sub"],
    email: claims["email"],
    firstName: claims["given_name"],
    lastName: claims["family_name"],
    profileImageUrl: claims["picture"],
  });
}

export async function setupAuth(app: Express) {
  app.set("trust proxy", 1);
  app.use(getSession());
  app.use(passport.initialize());
  app.use(passport.session());

  const config = await getOidcConfig();

  const verify: VerifyFunction = async (
    tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
    verified: passport.AuthenticateCallback
  ) => {
    try {
      const user = {};
      updateUserSession(user, tokens);
      await upsertUser(tokens.claims());
      verified(null, user);
    } catch (error) {
      console.error("Auth verification error:", error);
      verified(error, null);
    }
  };

  const callbackURL = process.env.APP_URL ? `${process.env.APP_URL}/api/callback` : `http://localhost:5000/api/callback`;
  console.log(`[AUTH] Using callback URL: ${callbackURL}`);
  console.log(`[AUTH] APP_URL environment variable: ${process.env.APP_URL || 'not set'}`);
  
  const strategy = new Strategy(
    {
      name: "auth0",
      config,
      scope: "openid email profile offline_access",
      callbackURL,
    },
    verify,
  );
  passport.use(strategy);

  passport.serializeUser((user: Express.User, cb) => cb(null, user));
  passport.deserializeUser((user: Express.User, cb) => cb(null, user));

  app.get("/api/login", (req, res, next) => {
    passport.authenticate("auth0", {
      prompt: "login consent",
      scope: ["openid", "email", "profile", "offline_access"],
    })(req, res, next);
  });

  app.get("/api/callback", (req, res, next) => {
    passport.authenticate("auth0", {
      successReturnToOrRedirect: "/",
      failureRedirect: "/api/login",
    }, (err: any, user: any, info: any) => {
      if (err) {
        console.error("Auth0 callback error:", err);
        console.error("Error details:", JSON.stringify(err, null, 2));
        return res.status(500).json({ error: "Authentication failed", details: err.message });
      }
      if (!user) {
        console.error("Auth0 callback failed - no user:", info);
        return res.redirect("/api/login");
      }
      req.logIn(user, (err) => {
        if (err) {
          console.error("Login session error:", err);
          return res.status(500).json({ error: "Session creation failed" });
        }
        res.redirect("/");
      });
    })(req, res, next);
  });

  app.get("/api/logout", (req, res) => {
    req.logout(() => {
      const returnTo = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
      const logoutUrl = `https://${process.env.AUTH0_DOMAIN}/v2/logout?client_id=${process.env.AUTH0_CLIENT_ID}&returnTo=${encodeURIComponent(returnTo)}`;
      res.redirect(logoutUrl);
    });
  });
}

export const isAuthenticated: RequestHandler = async (req, res, next) => {
  const user = req.user as any;

  if (!req.isAuthenticated() || !user.expires_at) {
    return res.status(401).json({ message: "Unauthorized" });
  }

  const now = Math.floor(Date.now() / 1000);
  if (now <= user.expires_at) {
    return next();
  }

  const refreshToken = user.refresh_token;
  if (!refreshToken) {
    return res.redirect("/api/login");
  }

  try {
    const config = await getOidcConfig();
    const tokenResponse = await client.refreshTokenGrant(config, refreshToken);
    updateUserSession(user, tokenResponse);
    return next();
  } catch (error) {
    return res.redirect("/api/login");
  }
};

1.4 Set Environment Variables for Auth0

Add these to your Replit Secrets:

AUTH0_DOMAIN=your-tenant.eu.auth0.com
AUTH0_CLIENT_ID=your_client_id_here
AUTH0_CLIENT_SECRET=your_client_secret_here
APP_URL=https://your-app-name.replit.dev or production domain later

1.5 Auth0 production mode and Social logins

If you want to use Auth0 App in production mode, change your tenant tag to production (Auth0 provides one tenant in free plan) or create new tenant. Dev mode uses Auth0 development keys that cannot be used in prod.
Set the Social logins keys, Auth0 has good docs for setting them up.
Configure auth providers:

Dashboard → Authentication → Social → Create Connections


Step 2: Migrate Database (Replit DB → Neon PostgreSQL)

Replit’s database is powered by Neon, so migrating to your own Neon instance is straightforward.

2.1 Set Up Neon Account

  1. Create Account: Sign up at Neon.tech
  2. Create Project: Choose an EU region (e.g., eu-central-1)
  3. Create Database: Use PostgreSQL 16 for compatibility

2.2 Export Data from Replit

Data exporting and migration is outside of this guide but there are tools for dumping/replicating data.
Note that users will get different userId when logging in trough Auth0 so it might cause problems for your existing data.

2.3 Set Up New Database Connection

  1. Get Connection String: Copy from Neon dashboard, choose Dev or Prod
  2. Update Environment Variable:
    DATABASE_URL=postgresql://****.aws.neon.tech/dbname?sslmode=require
    

2.4 Migrate Schema and Data

Do this for Neon Dev and Prod branches to seed/update DB schema

# Push schema to new database
npx drizzle-kit push


Step 3: Migrate App Storage (Replit → Google Cloud Storage)

Replit App Storage uses Google Cloud Platform buckets, so we’ll create our own GCP bucket in an EU region.
AWS Cloud Storage works too but Im more used to GCP myself.

3.1 Set Up Google Cloud Storage

  1. Create GCP Account: Sign up at GCP Console
  2. Create Project: Name it appropriately
  3. Create Bucket:
    • Choose EU region (e.g., europe-west1)
    • Set appropriate permissions

3.2 Generate HMAC Keys

  1. Navigate to Bucket Storage Settings
  2. Create HMAC Key: Generate access key and secret for service account (or create new service account in the process)
  3. Save Credentials: Store securely

3.3 Update Storage Code

Its best to tell Agent to change the storage code for Replit App Storage to use GCP bucket with AWS S3 client.
S3 client is simpler than GCP client and this way you can use the HMAC keys.

3.4 Environment Variables

GCS_ACCESS_KEY=your_hmac_access_key
GCS_SECRET_KEY=your_hmac_secret_key
GCS_BUCKET_NAME=your-bucket-name

Step 4: Migrate Application Hosting (Replit → Fly.io)

Fly.io offers excellent multi-region hosting options and Docker compatibility.

4.1 Prepare Your Application

At least on my apps the server/vite.ts file uses static import for Vite that will cause error in deployment because Vite cannot be found.
The vite.ts file protected from Agent, so ask for what needs to be changed and make the changes.
Fix the Vite dynamic import issue in server/vite.ts. Here’s example from my apps:

import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import { type Server } from "http";
import { nanoid } from "nanoid";


export function log(message: string, source = "express") {
  const formattedTime = new Date().toLocaleTimeString("en-US", {
    hour: "numeric",
    minute: "2-digit",
    second: "2-digit",
    hour12: true,
  });

  console.log(`${formattedTime} [${source}] ${message}`);
}

export async function setupVite(app: Express, server: Server) {
  const { createServer: createViteServer, createLogger } = await import("vite");
  const viteConfig = {
    plugins: [
      (await import("@vitejs/plugin-react")).default(),
      (await import("@replit/vite-plugin-runtime-error-modal")).default(),
    ],
    resolve: {
      alias: {
        "@": path.resolve(import.meta.dirname, "..", "client", "src"),
        "@shared": path.resolve(import.meta.dirname, "..", "shared"),
        "@assets": path.resolve(import.meta.dirname, "..", "attached_assets"),
      },
    },
    root: path.resolve(import.meta.dirname, "..", "client"),
    build: {
      outDir: path.resolve(import.meta.dirname, "..", "dist/public"),
      emptyOutDir: true,
    },
  };
  const viteLogger = createLogger();

  const serverOptions = {
    middlewareMode: true,
    hmr: { server },
    allowedHosts: true as const,
  };

  const vite = await createViteServer({
    ...viteConfig,
    configFile: false,
    customLogger: {
      ...viteLogger,
      error: (msg, options) => {
        viteLogger.error(msg, options);
        process.exit(1);
      },
    },
    server: serverOptions,
    appType: "custom",
  });

  app.use(vite.middlewares);
  app.use("*", async (req, res, next) => {
    const url = req.originalUrl;

    try {
      const clientTemplate = path.resolve(
        import.meta.dirname,
        "..",
        "client",
        "index.html",
      );

      // always reload the index.html file from disk incase it changes
      let template = await fs.promises.readFile(clientTemplate, "utf-8");
      template = template.replace(
        `src="/src/main.tsx"`,
        `src="/src/main.tsx?v=${nanoid()}"`,
      );
      const page = await vite.transformIndexHtml(url, template);
      res.status(200).set({ "Content-Type": "text/html" }).end(page);
    } catch (e) {
      vite.ssrFixStacktrace(e as Error);
      next(e);
    }
  });
}

export function serveStatic(app: Express) {
  const distPath = path.resolve(import.meta.dirname, "public");

  if (!fs.existsSync(distPath)) {
    throw new Error(
      `Could not find the build directory: ${distPath}, make sure to build the client first`,
    );
  }

  app.use(express.static(distPath));

  // fall through to index.html if the file doesn't exist
  app.use("*", (_req, res) => {
    res.sendFile(path.resolve(distPath, "index.html"));
  });
}

4.2 Set Up Fly.io

When your app starts to be ready for deployment:

  1. Create Account: Sign up at Fly.io
  2. Install CLI: In Replit shell, run:
    nix-shell -p flyctl
    

If you want you can install the Fly client in Replit depencencies, so you dont have to run the nix-shell command every time Replit starts.

4.3 Initialize Fly.io App

# Launch setup wizard, it will ask you to login to Fly
flyctl launch

# Follow prompts:
# - Set app name
# - Choose a EU region DC for location ...

Setup will create fly.toml that contains app config and a Dockerfile

4.4 Configure Dockerfile

After the initial setup, check your Dockerfile listens on the correct port. Replit uses port 5000 as default:

# Check this line in Docker file.
EXPOSE 5000

If you use VITE_PUBLIC_XX variables they have to be initialized in Docker file with ENV statements.

4.5 Set Environment Variables

Export your secrets from Replit and set them in Fly.io, example:

# Set individual secrets
flyctl secrets set AUTH0_DOMAIN=your-tenant.eu.auth0.com
flyctl secrets set AUTH0_CLIENT_ID=your_client_id
flyctl secrets set AUTH0_CLIENT_SECRET=your_client_secret
flyctl secrets set DATABASE_URL=your_neon_connection_string
flyctl secrets set GCS_ACCESS_KEY=your_gcs_key
flyctl secrets set GCS_SECRET_KEY=your_gcs_secret
flyctl secrets set APP_URL=https://your-app.fly.dev
...

# Or import as .env file
fly secrets import < exported_env-file

4.6 Deploy to Fly.io

# Deploy your application
flyctl deploy -c fly.toml

Your app is now deployed an running! By default Fly will create two VM’s for redundancy but you can configure the number of instances.


Step 5 Production updates for DNS and URLs

  1. Update Auth0: Change/Add callback URLs to production domain
  2. Update APP_URL: Point to your custom domain URL
  3. Update DNS records for your domain: get Fly app ip address.
  4. Get app ip’s and generate certs:
    flyctl ips list
    flyctl certs add your-domain.com
    flyctl certs add www.your-domain.com
    

Conclusion

Now you have control over your data location and processing region.
Hope this guide helps those who want to develop with Replit and Agent and want to deploy anywhere. Reply if you have any questions or feedback.
I know there are other alternative hosting and authentication methods too (Supabase, Firebase…) but the more choices the better.


4 Likes