Deploy Guide
0 / 7 done

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?

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.

    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.

    Node / Express: server/src/index.ts
    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", () => { /* ... */ });
    Python / FastAPI: main.py
    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.

    Dockerfile, real example, line by line (SmartChats: Vite frontend + Express backend)
    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.
    Dockerfile: Python (FastAPI)
    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.)

    Dockerfile: fully commented template
    # --- 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.

    Local, run with docker compose up
    docker-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
    Server, what Coolify deploys
    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.

    coolify-docker-compose.yml: fully commented template
    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.

    1. Install Docker

      Docker Desktop on Mac or Windows, Docker Engine on Linux. Check with docker --version.

    2. 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 .env is in .gitignore.

    3. Build and start

      Run docker compose up --build and wait for your server's "listening" line.

    4. Click around for real

      Open localhost:3001 and your health route. Log in, hit the API, upload something, whatever your users do.

    5. 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.

    terminal: with compose
    docker compose up --build   # build + run
    docker compose logs -f      # watch logs
    docker compose down         # stop
    
    curl localhost:3001/api/health
    terminal: just a Dockerfile
    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.

    copy into your Plane request
    ## 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).