Mixed Group Research · Hosting Guide
Move your project from Render (or anywhere) onto our Coolify server.
A step-by-step path from Render, Northflank, Railway, Vercel or your laptop to a container that runs on the group's shared server, written for people who have never touched Docker.
Your migration checklist
7 steps from your old host to our server.
Step 0: How our setup works
First, how our server works.
Four things to know before you touch any code.
shared
One Coolify server runs every team's project.
Coolify builds your repo into a container and its proxy points your domain at it.
git push
Push to your deploy branch and it auto-deploys, after a one-time webhook setup.
No dashboard login for now. Once your app exists in Coolify, we send you a webhook URL to add to your GitHub repo, that's what makes push-to-deploy fire. It doesn't exist until after your first deploy, so it can't be part of your initial hand-off (see Step 6).
discord
A bot posts whether each deploy worked.
You'll know within minutes if your push made it or failed, in #coolify-status-alerts.
no logs
You can't see server logs, so test in local Docker first.
If docker compose up works on your machine, it will almost always work on ours.
The whole loop, once you're set up
1 · your laptop
Build & run in Docker
docker compose up works
2 · git
Push to your deploy branch
Push or merge a PR
3 · server
Coolify builds it
Same image you tested
4 · live
Up on your domain
Proxy routes traffic in
5 · discord
Bot posts the result
Deployed, or failed
Need something on the server side?
Post in #coolify-support (it's a forum, create a post) for any of these, we'll change it for you:
- Build or runtime logs from a failed deploy
- A domain or subdomain, or changing it
- Adding or updating environment variables
- Switching which branch deploys
Want more direct access (your own dashboard, your own logs)? Tell us what you need, we're working out how to support it.
What we can't (or shouldn't) host
- GPU workloads. Our VMs have no GPUs, keep those where they are (Northflank, Modal, etc.).
- Free managed services you already use. Supabase, Firebase and similar free tiers can stay put, only move the app. Want to self-host one of these instead? Talk to us first.
- Large files. Storage is shared across every team. Anything past roughly 10-20 GB (audio, video, datasets) needs a conversation first, options are a folder next to your app, a MinIO bucket (S3-compatible), or hosted S3.
Step 1: Identify what you're hosting
Three questions decide how you deploy.
Pick your answers and we'll tell you which Coolify build pack to use, how many containers you need, and what files you have to write.
1. What's your server written in?
2. Where does your frontend live?
3. Anything running next to it?
What's hostable alongside your app
Plenty of database options exist beyond Supabase. Coolify can spin up Postgres, MySQL, MariaDB, MongoDB, Redis, Dragonfly, KeyDB, or ClickHouse directly as a resource, and services like PocketBase are one click away too. Pick whatever fits your project, not just the one everyone defaults to.
Want to self-host an all-in-one platform (Supabase, PocketBase, Appwrite)? Talk to us first. These bundle a database with auth, storage, and realtime, which usually means several containers each, one self-hosted Supabase instance alone is about seven. For most projects here, Supabase's free managed tier already covers what you need without the extra containers. If yours doesn't, post in #coolify-support and we'll figure out storage and sizing together, we're happy to provision for it.
Talk to us before you build this. How many containers you need, and especially whether you need real storage, changes what we set up for you. Open a post in #coolify-support before you commit to an architecture.
Your deploy plan
Coolify build pack
Pick your answers…
Containers
-
CORS needed
-
Files you write
Answer question 1 to see what's required.
Next steps
Step 2: One container or two
Let the backend serve the frontend.
If you have a Vite/React frontend and an API, the simplest setup is to build the frontend and have the backend hand out those files. One container, one domain, no CORS. It's usually a few lines.
Backend serves frontend
This is what we want- 1 container, 1 domain
mydomain.com/is the site,mydomain.com/api/…is the API- No CORS config, no API URL baked into the build
- Half the containers to build, run, and pay for
Separate frontend container
Talk to us first- 2 containers, 2 domains, for a problem one container already solves
- Backend must allow the frontend's origin (CORS), one more thing to misconfigure
- Frontend needs the API URL at build time (
VITE_API_URL) - On a shared server, every extra container is shared capacity someone else doesn't get
Default to one container. There's almost never a reason to run the frontend separately from the backend that serves its API, it's strictly more moving parts for the same result. If you think your project is the exception, that's a conversation to have with us before you build it that way, not after.
This is the real pattern from our own SmartChats migration, Express serving a built Vite app, with everything that isn't /api falling back to index.html so client-side routing still works.
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// after all your /api routes
const CLIENT_DIST_DIR = path.join(__dirname, "..", "..", "dist");
if (fs.existsSync(CLIENT_DIST_DIR)) {
app.use(express.static(CLIENT_DIST_DIR));
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(path.join(CLIENT_DIST_DIR, "index.html"));
});
}
app.listen(PORT, "0.0.0.0", () => { /* ... */ });
from fastapi.staticfiles import StaticFiles
# after all your /api routes
app.mount("/", StaticFiles(
directory="frontend/dist", html=True
), name="site")
Step 3: Write a Dockerfile
The recipe for your container.
A single Node server can skip this, Coolify's Nixpacks or Railpack build pack figures it out from package.json in one step. Python servers, anything unusual, and anything with more than one service need a Dockerfile. Start from one of these.
This is a multi-stage build: the first stage (build) has all your source and dev tools and produces compiled output; the second stage (runtime) starts clean and only copies in that output. The result is a small final image with no source code, dev dependencies, or build tools sitting in it.
FROM node:20-slim AS buildStart from a slim Node 20 image, call this stage "build".WORKDIR /appEvery command below runs inside /app in the container.COPY package.json package-lock.json ./Copy only the dependency manifests first, not the whole repo yet.RUN npm ciInstalls exact versions from the lockfile (stricter and faster than npm install). Copying manifests before source code means Docker can reuse this layer on the next build if dependencies didn't change, that's the whole trick.COPY server/package.json server/package-lock.json ./server/Same trick, for the backend's own dependencies.RUN npm ci --prefix server--prefix server runs the install as if you'd cd server first.COPY . .Now copy everything else, frontend source and backend source both.RUN npm run buildBuilds the frontend (Vite outputs static files to dist/).RUN npm run build --prefix serverBuilds the backend too. This project's server is TypeScript, so "build" here means compiling to plain JS, not something every backend needs, but if yours does (TypeScript, bundling), it happens here.FROM node:20-slim AS runtimeSecond stage. Starts over from a clean image, nothing from the build stage carries over unless copied explicitly.WORKDIR /appSame working directory convention in this stage.ENV NODE_ENV=productionTells Node (and libraries that check it) to run in production mode.COPY server/package.json server/package-lock.json ./server/Need the manifest again in this fresh stage to install for real.RUN npm ci --prefix server --omit=dev--omit=dev skips devDependencies (test runners, bundlers, type checkers), you don't need those to run the app.COPY --from=build /app/dist ./distPull the built frontend out of the build stage. No frontend source code ends up in this image, just the compiled output.COPY --from=build /app/server/dist ./server/distSame for the compiled backend.COPY server/texts ./server/textsProject-specific: this app reads a text corpus at runtime, so it's copied in directly rather than built. Your equivalent is whatever static files your app needs that aren't source code.EXPOSE 3001Documents which port the app listens on. This is metadata, not what actually opens the port, see Step 4 for that.CMD ["node", "server/dist/index.js"]The command that runs when the container starts.FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", \
"--host", "0.0.0.0", "--port", "8000"]
Listen on 0.0.0.0
A server bound to localhost inside a container can't be reached from outside it, including by Docker's own port mapping. Bind to 0.0.0.0; it works locally too.
Add a .dockerignore
List node_modules, .env, .git and build output so they never get copied into the image.
No secrets in the image
Keys and passwords come in as environment variables at run time, never COPY .env or hard-code them.
Add a health check route
A tiny endpoint like /api/health that returns 200 lets Coolify know your app actually started.
why this matters here
You don't get server logs. A health check is one of the only signals you'll have.
Without one, Coolify only knows your container is running, not that your app actually came up and can serve traffic. A process that crashed on boot but left the container alive looks identical to a working deploy from the outside. A health check that hits a real route (not just "the server exists") is what turns a silent failure into something the Discord bot can actually report. It also goes straight into your hand-off request in Step 6, so decide on one now rather than guessing it later.
Want a fully-loaded starting point?
A Dockerfile with every common option, commented, that you can delete down to what you need. (You can also just ask Claude to generate one for your specific stack, it's good at this.)
# --- build stage: has dev tools, produces compiled output ---
FROM node:20-slim AS build
WORKDIR /app
# copy manifests before source so dependency installs are cached
# between builds when only your code changes, not your deps
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # -> outputs to /app/dist
# --- runtime stage: clean image, only what's needed to run ---
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev # skip devDependencies
COPY --from=build /app/dist ./dist
# COPY any other runtime assets here (texts, seed data, etc.)
# do NOT copy .env, secrets, or source maps you don't want public
# optional: run as a non-root user instead of the container default root
# RUN addgroup --system app && adduser --system --ingroup app app
# USER app
ENV PORT=3001
EXPOSE 3001 # documents the port; see compose for what opens it
# optional: let Docker check the app is actually up, not just running
# HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
# CMD node -e "fetch('http://localhost:3001/api/health').then(r=>process.exit(r.ok?0:1))"
CMD ["node", "dist/index.js"]
Step 4: Two compose files
One for your laptop, one for the server.
On the server, Coolify's proxy decides which domain reaches which port, so the compose file there uses expose instead of mapping ports. That breaks on your machine, where there's no proxy, so keep two files that are otherwise identical.
required
coolify-docker-compose.yml must use expose, not ports.
This isn't a style preference. If you use ports: on the server compose file, Coolify's proxy can't route your domain to the container, and/or you risk binding a port directly on a machine every other team's app also runs on. expose: is what makes your app reachable only through the proxy, which is what Coolify expects.
docker compose updocker-compose.yml
services:
app:
build: .
ports:
- "3001:3001" # host:container
environment:
PORT: 3001
OPENAI_API_KEY: ${OPENAI_API_KEY}
SUPABASE_URL: ${SUPABASE_URL}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
healthcheck:
test: ["CMD", "node", "-e",
"fetch('http://localhost:3001/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
coolify-docker-compose.yml
services:
app:
build: .
expose:
- "3001" # proxy routes your domain here
environment:
PORT: 3001
OPENAI_API_KEY: ${OPENAI_API_KEY}
SUPABASE_URL: ${SUPABASE_URL}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
healthcheck:
test: ["CMD", "node", "-e",
"fetch('http://localhost:3001/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
ports → expose
Mapping 3001:3001 on a shared server would grab that port for the whole machine and collide with other teams. expose only opens it to the proxy.
env vars → ${VAR}
Coolify reads every ${VAR} in the file and shows it as a field we fill in on the server. List every variable your app needs this way, anything missing won't exist in production.
More than one service?
Add each (worker, Redis, etc.) under services: in both files. Services reach each other by name, redis://redis:6379, not by localhost.
A health check block like the one above isn't strictly required, but Coolify (and Docker itself) can use it to know your container is actually ready, not just running.
How these pieces fit together
Dockerfile
The recipe. Describes how to build one container image from your code.
Image
The result of running that recipe once. A built, runnable snapshot.
Compose file
Says how to run that image (or several): which ports, which env vars, which other containers.
Coolify
Reads coolify-docker-compose.yml, builds the image itself, and runs it with its proxy routing your domain to whatever you exposed.
Volumes: should you use one?
A volume is storage that survives a redeploy, anything written to a container's normal filesystem is gone the moment it restarts. If your app needs to keep something (uploads, generated files, a local database), you have two options.
Use a database instead (preferred)
If what you're storing is structured data, use a plain database container (Coolify spins up Postgres, MySQL, MariaDB, MongoDB, Redis and others directly), or your own free-tier project on a managed service like Supabase or Neon. Disk on the shared server is shared across every team, a database you already understand and can back up is the safer default.
If you actually need a volume
Talk to us first, post in #coolify-support. We'll figure out size and whether it should be a local volume or a MinIO bucket. Don't assume local storage will persist until that conversation has happened.
Want a fully-loaded starting point?
A coolify-docker-compose.yml with every common option, commented, that you can delete down to what you need.
services:
app:
build: .
expose:
- "3001" # REQUIRED on the server: proxy routes here,
# never use `ports:` in this file
environment:
PORT: 3001
DATABASE_URL: ${DATABASE_URL}
OPENAI_API_KEY: ${OPENAI_API_KEY}
# add every env var your app reads; Coolify turns each ${VAR}
# into a field you fill in on the server
healthcheck: # optional, but lets Coolify/Docker know
test: ["CMD", "node", "-e", # the app is actually ready, not just running
"fetch('http://localhost:3001/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# optional: only if you talked to us about a volume first
# volumes:
# - app-uploads:/app/uploads
# optional: a second service, e.g. a worker or Redis
# worker:
# build: .
# command: ["node", "dist/worker.js"]
# environment:
# DATABASE_URL: ${DATABASE_URL}
# redis:
# image: redis:7-alpine
# expose:
# - "6379"
# optional: only if a volume was approved
# volumes:
# app-uploads:
Step 5: Test it locally
If it runs in Docker on your machine, it runs on ours.
This is the step that saves everyone time. You won't have server logs, so find problems here, where you can see everything.
-
Install Docker
Docker Desktop on Mac or Windows, Docker Engine on Linux. Check with
docker --version. -
Add your real .env
Put it in the repo root, next to
docker-compose.yml, with the same values you use in production. Make sure.envis in.gitignore. -
Build and start
Run
docker compose up --buildand wait for your server's "listening" line. -
Click around for real
Open
localhost:3001and your health route. Log in, hit the API, upload something, whatever your users do. -
Do it once from a fresh clone
Clone into a new folder, add
.env, and run again. That's exactly what the server does, and it catches files you forgot to commit.
docker compose up --build # build + run
docker compose logs -f # watch logs
docker compose down # stop
curl localhost:3001/api/health
docker build -t myapp .
docker run --env-file .env -p 3001:3001 myapp
It's ready when…
- It builds from a fresh clone with no manual steps
- The site loads at
/and the API answers - No errors in the logs with your real env vars
- Restarting the container doesn't lose data you care about
Step 6: Hand it off
Send us this, and we deploy it.
Submit it as a request on the Coolify Deployment Requests board in Plane. Open a post in #coolify-support on Discord (it's a forum channel, create a new post) if you want a heads-up or have questions, that channel is for conversation, not where status posts go (that's #coolify-status-alerts, after you're live). Once we hand you a webhook URL (see below), every push to your deploy branch redeploys on its own.
## Coolify Deployment Request
**Overview:**
Hello, I have a Node.js server and want to move from Render to Coolify so I don't have to pay for it.
**Server Type:**
Node.js, frontend and backend (one container, backend serves the frontend)
(or: static site only / API-only / other, see Step 1 above if unsure)
**GitHub link:**
github.com/[org]/[repo]
**Branch to deploy from:**
main
**Private or Public:**
Private. Added the Coolify deploy key from setup.mixed.group
as a read-only deploy key.
**Root filepath:**
./ (or ./server/serverv2 if your app isn't at repo root)
**Build method:**
Automatic / Dockerfile / Docker Compose, pick one
(Automatic = Coolify builds it for you from package.json,
no file needed, just say "automatic." Pick Dockerfile or
Compose only if you wrote one.)
**Static site file path (only if this is a static site, no backend):**
Not applicable, this has a backend.
**Compose file location (if using Compose):**
./coolify-docker-compose.yml
**Port:**
3000
(Skip this if you picked "static site," it doesn't apply.)
**Health check path:**
/api/health
(Skip this if you picked "static site." See Step 3 above
for why this matters, it's one of your only signals when
something breaks since we don't give out server logs.)
**Env vars needed (names only, not values):**
- SUPABASE_URL
- SUPABASE_KEY
- DATABASE_URL
**Storage / volumes:**
Postgres runs as a second service in my compose file. No
media or file storage, data only. Max ~1GB.
**Domain:**
[what you'd like, e.g. yourapp.mixed.group. We can't do
double nesting, so something.something.mixed.group won't work.]
**Tested locally with docker compose up --build (or the
equivalent Node command if using Automatic):**
Yes, screenshot attached.
(If this is a static site with no Docker setup, say
"N/A, static site" here instead.)
Secrets: never in Plane, Discord, or the repo
List env var names in your request, never the values. Post in #coolify-support and we'll arrange a one-time secure way to get the actual values to us, we paste them directly into Coolify's environment panel. They're never written into a chat log or a ticket.
Private repo?
Add our deploy key as a read-only key on your repo (GitHub → Settings → Deploy keys) so the server can pull it without you sharing ownership. It's a public key, safe to post anywhere, grab it from setup.mixed.group:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHbjv9w3Y+mHFlmfZ7c4ejx2TKHkQgrE6N+fSwVYKAMx
We're moving toward a shared GitHub organization over time; until then this works for private repos you don't want to hand over.
Auto-deploy needs a webhook, added after your first deploy
The deploy key above only lets Coolify pull your repo, it doesn't make pushes trigger a redeploy on its own. Each app gets its own webhook URL once it exists in Coolify, so we can't hand it to you up front. After your first deploy, we'll send you that URL to add under GitHub → Settings → Webhooks on your repo. Until it's added, pushing to your deploy branch won't redeploy automatically, you'd have to ask us to redeploy manually.
Migrating from Render, Northflank, Railway…
Your env vars are already in your old dashboard, export the list from there. Keep the old deployment running until we confirm the new one works, then switch your domain and shut it down.
Changing branches later
Cleaning up and want to deploy from main instead? Tell us before you switch and we'll repoint it.
Deploy failed?
Re-run it from a fresh clone locally first. If it works there and still fails on the server, post in #coolify-support and ask for the build logs.
Docker, in plain words
Nine terms you'll run into.
Docker packages your app with everything it needs so it runs the same on your laptop and on the server. That's the whole point, and it's why local testing works.
Image
A packaged snapshot of your app plus its runtime and dependencies. Built from a Dockerfile.
Container
A running copy of an image. Isolated and disposable, anything written inside it is gone on redeploy.
Dockerfile
The recipe: a base image, install dependencies, copy your code, and the command that starts it.
Compose file
A YAML file describing one or more containers and how they're wired, ports, env vars, volumes.
ports
Publishes a container port on your machine, so localhost:3001 works. Local only.
expose
Opens a port only to other containers and the proxy. What our server wants.
Environment variables
Config and secrets handed to the container when it starts, never baked into the image.
Build pack
How Coolify turns your repo into an image: Nixpacks/Railpack (automatic), Dockerfile, or Docker Compose.
Volume
Storage that survives redeploys. Prefer a database for structured data; post in #coolify-support before relying on a volume, disk is shared (see Step 4).