Aug 07 2026

Setting up a self-hosted PaaS usually means spending time SSHing into plain servers, struggling with Docker Swarm configurations, and tweaking proxy rules.
With our new Dokploy image on Strettch Cloud Compute, you can skip that entire setup ritual. The image comes already installed on an Ubuntu server, pre-configured with Docker, Traefik, and baseline security tooling baked directly onto the server, giving you a ready deployment engine without the provisioning headache.
To see this image in action, we will be deploying Folio, an open-source reading curriculum tracker built with a Vue 3 frontend, Express API, and MongoDB database. Because the project is public on GitHub, you can easily follow along, clone the code, or test the process before deploying your own stack.
In this step-by-step guide, we will look at how Dokploy initializes via a quick setup script during your first SSH login, walk through launching Folio's backend and frontend, and the final section will explore errors I got as a beginner using Dokploy and what I found helpful when troubleshooting.
Architecture overview:

The browser only ever talks to Traefik, on ports 80 and 443. Traefik reads the subdomain in the URL and hands the request to the right container: frontend-folio goes to the nginx container holding the built Vue files, backend-folio goes to the Express container. The Dokploy dashboard on port 3000 is only there for you to manage deployments, it plays no part in serving the app.
One detail is worth pointing out, because it differs from a setup where you host your own database. The frontend is nothing but static files, and the Vue app itself runs in your visitor's browser. So when it calls the API, those requests travel over the public internet to backend-folio rather than through any internal network, which is also why the backend needs CORS enabled. The API then connects out to MongoDB Atlas, which is not on your Compute at all. Atlas is managed and reached over the internet, so it is the one box in that diagram not running on your server.
The Bonus Step at the end of this guide merges the two subdomains into a single one, which removes that cross-origin hop entirely.
As you enter the platform, you will see a Create Compute button. Click that, and you will see a form to fill in all the required information for your desired Compute.
Since we are using Dokploy, it needs a Linux server to run on. Picking the Dokploy option when you select an application image saves you installing all of that by hand. Everything Dokploy needs is already on the image, and the setup script mentioned above finishes the job the first time you log in.

What is an "application image"?
An image is a ready-made snapshot of a server's hard drive that gets copied onto your new Compute the moment it boots. Instead of starting from a totally blank machine, you start from whatever was already installed and saved into that snapshot. Strettch Cloud's Dokploy image is a good example: under the hood it is a stock Ubuntu 24.04 server with Docker and everything Dokploy needs already downloaded and saved into the snapshot, so the slow part is done before your Compute even boots. Pick "Ubuntu" and you get a blank slate; pick "Dokploy" and you get that same Ubuntu slate with the hard part already out of the way.
Once everything shows green checkmarks in the Summary panel on the right, click Create Compute. Once it is running, note its public IP address. Strettch Cloud shows it on the Compute's detail page, and you will need it both for SSH below and for DNS in the next step.
There are two ways into the machine and you only need one of them. From the Strettch Cloud dashboard, open the Compute and click Go to console for a terminal in the browser. Or SSH in from your own terminal, which is what I did.

If you are going the SSH route, connect as root:
ssh root@YOUR_COMPUTE_IP
The Compute also listens on port 222, so the following works if your network blocks port 22:
ssh root@YOUR_COMPUTE_IP -p 222
Either way, the setup wizard starts on its own the first time you connect. It asks three questions:
Make sure the email, name, and password are something you remember, because these details will be used to log into your Dokploy dashboard.
When setup finishes, it prints a URL: http://YOUR_COMPUTE_IP:3000.
Warning: Do not log in over that URL directly. It is plain HTTP with no certificate yet, so your admin email and password would travel across the network in the clear, and that account controls your deployments, your environment variables, and your connected GitHub App. Reach the dashboard through an SSH tunnel instead, which encrypts the whole session:
ssh -L 3000:localhost:3000 root@YOUR_COMPUTE_IP -p 222Leave that terminal open and go to
http://localhost:3000in your browser. The address bar still sayshttp, but the traffic is travelling inside the SSH connection, so nothing on the network in between can read it. Log in with the email and password you just set.
Now you are ready to create your first project.
Do this before deploying anything. Traefik (Dokploy's built-in reverse proxy) requests an SSL certificate the first time it sees a domain attached to a service, and that only succeeds if DNS already resolves to your Compute. So getting DNS right early saves a redeploy later.
Where you add these records depends on who runs your domain's DNS. If your nameservers point at Strettch Cloud, you add them from the dashboard as shown below. If your domain sits with another provider such as Cloudflare or Namecheap, add the same records there instead and skip ahead to the table.
On your Strettch Cloud dashboard, you will see three options: Computes, Domains, and Team Settings. Click on Domains.

Click on Setup Domain and fill in the details. Something worth noting is that your main domain should be valid. Strettch Cloud does not offer domains so far, so it is up to you to give us a valid domain and walk through these steps to set it up on your Compute.

Folio needs two subdomains, one per service. In your DNS provider, add two A records pointing at your Compute's public IP. (You will see the option of which Compute's IP to direct to when selecting, as seen below.)
| Type | Hostname | Directs to |
|---|---|---|
| A | frontend-folio | YOUR_COMPUTE_IP |
| A | backend-folio | YOUR_COMPUTE_IP |


Give DNS a few minutes to propagate before moving on.
Dokploy needs read access to your repo to clone it and rebuild on push. In the dashboard, go to Settings → Git, then click GitHub under Available Providers.

This kicks off GitHub's app-installation flow, where Dokploy creates a GitHub App on your behalf, you approve it, and you pick which repositories it can access. Once connected, it shows up as a named entry (for example, dokploy-blog-folio) with a green "connected" indicator.
Note: If you are deploying to an organization's repos rather than your personal account, you may hit permission errors here depending on the organization's security policies. Some organizations restrict third-party GitHub App installs to admins only, or block SSH deploy keys entirely in favor of Apps. If the GitHub connection stalls or errors, it's usually an org-permissions issue rather than something wrong with Dokploy itself; check with whoever administers the organization's GitHub settings.
Back in the dashboard, go to Projects → Create Project. Give it a name (in our example it is folio) and an optional description, then click Create.


Inside the project, you land on its production environment. This is where both Folio services will live. Click Create Service → Application to add the first one.

On the new application's General tab, under Provider, select GitHub, then fill in:
main/ was enough for Folio, but depending on your codebase structure, you can define the path accordingly.Below the Provider section, there is the Build Type section, which tells Dokploy how to build your app. New applications start on Nixpacks, which works the build out for you with no configuration. That is the easier option when it works, and for a lot of apps it does. However, it will not build Folio's frontend. Nixpacks builds from a pinned set of packages whose newest Node is older than the one Vite needs, and there is no setting that raises it. So for the frontend, switch Build Type to Dockerfile. Folio has a Dockerfile in both the frontend and the backend folders.
What is a Dockerfile?
A Dockerfile is a text file containing instructions for building your source code. The default filename to use for a Dockerfile in your repository is
Dockerfile, without a file extension. Using the default name allows you to run thedocker buildcommand without having to specify additional command flags.
This is the Dockerfile Folio uses for the frontend. It builds the Vue app with Node 22, then copies the finished files into nginx to serve them:
# Frontend: Vue 3 + Vite, served as static files by nginx
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
The real file has one more line after the COPY, a printf that writes a small nginx config so that vue-router paths fall back to index.html instead of returning a 404. Copy that line from the repo, it is long and easy to mistype.
The two lines in the middle matter for the next part. ARG VITE_API_BASE_URL and ENV VITE_API_BASE_URL=$VITE_API_BASE_URL let the build read a value you set in Dokploy.
It is worth understanding why the frontend and the backend handle this differently, because it is the part that trips most people up. The backend runs on the server, so when server.js asks for process.env.MONGO_URI, it is genuinely reading an environment variable at the moment it starts. The frontend does not run on your server at all. It runs in your visitor's browser, and a browser has no access to your container's environment. So Vite deals with it at build time instead: when it compiles, it finds every import.meta.env.VITE_API_BASE_URL in your code and swaps in the actual text of the value. By the time the container starts, the address is already sitting in the JavaScript as a plain string.
So the two go in different places. Anything the browser needs goes in Build Time Arguments, because it has to be there while the image is being built. Anything the server needs goes in the Environment tab, because Node reads it when the container starts. The Environment tab is the nicer of the two when you have the choice, since you can change a value and restart without rebuilding anything.
Set the frontend one under Build Time Arguments:
VITE_API_BASE_URL=https://backend-folio.YOUR_DOMAIN
This also explains the failure that wasted the most time for me. Putting a VITE_ variable in the Environment tab does nothing whatsoever. The value lands in the running nginx container where nothing reads it, and the JavaScript was compiled long before that, so no amount of redeploying changes what the browser fetches.
Note: One more thing worth remembering is that anything you put in a
VITE_variable ends up as readable text in the bundle you ship to every visitor. Anyone can open their browser tools and read it. An API address is fine there. A password or an API key never is, those belong to the backend and go in the Environment tab.
Click Save, then Deploy. Confirm the prompt, and watch the build logs stream. A successful run ends with something like Docker build completed, or Done written in the deployment tab.
A running container is not reachable yet without a domain attached. Go to the Domains tab and click Add Domain:
frontend-folio.YOUR_DOMAIN (the subdomain from Step 2)/80. This has to match the port your container actually listens on. Folio's frontend Dockerfile builds to a static bundle served by nginx, and nginx defaults to port 80 inside the container.Once saved, you should see a green DNS Valid badge next to the domain, confirming Dokploy can see the A record resolving correctly. Open https://frontend-folio.YOUR_DOMAIN in a browser to confirm the frontend loads.
Back in the project's production environment, click Create Service → Application again for the backend.
The Provider setup mirrors Step 5: same repo, same branch with one key difference:
/server, since that's where Folio's Express API lives, separate from the frontend at the repo root.Set Build Type to Dockerfile again, and leave the Dockerfile field as Dockerfile.
Here is the backend Dockerfile. It is shorter than the frontend one because there is nothing to build, the API just runs:
# Backend: Express + Mongoose API
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
Before you deploy, open the Environment tab and add the two variables the API reads at startup:
MONGO_URI=your-atlas-connection-string
PORT=3000
Do this before the first deploy rather than after. Without them the container starts, fails to connect to the database, and exits, over and over. Keep both out of the repository. A committed .env file gets copied into the image when it builds, so the password travels with it, and anyone who can read the repo can read the password.
Now click Save, then Deploy, and watch for the same Docker build completed message as the frontend.
Go to Domains → Add Domain:
backend-folio.YOUR_DOMAIN/3000, or whatever port your Express app listens on. Folio's server.js reads process.env.PORT, which is the PORT you set in the Environment tab a moment ago.With both services deployed and their domains resolving, Folio is live: the frontend reachable at its subdomain, calling out to the backend at its own.
Two subdomains work fine, but it is cleaner (and avoids CORS entirely) to serve both services from a single domain, folio.YOUR_DOMAIN, using Traefik's path-based routing. Same domain, two Path values: / for the frontend, /api for the backend. The backend carries on as it is, but the frontend does need one rebuild at the end, for the reason explained below.
On the frontend service → Domains → Add Domain:
folio.YOUR_DOMAIN/80On the backend service → Domains → Add Domain:
folio.YOUR_DOMAIN/api/api; leave this matching Path, not stripped. Folio's Express routes are already mounted under /api (app.use('/api/curricula', ...)), so if Traefik's Strip Path option removes the /api prefix before forwarding, requests will arrive as /curricula and match nothing.3000Add one more DNS A record folio → your Compute's IP and give it a minute to propagate.
Finally, since the browser now calls the API on the same origin as the frontend, the frontend's build-time API URL collapses to a relative path. Set the Build Time Argument:
VITE_API_BASE_URL=
That is, leave it empty, so Folio's ${import.meta.env.VITE_API_BASE_URL}/api template resolves to just /api. This requires a Rebuild (not Deploy) of the frontend, since the value is compiled into the static JS bundle.
Once DNS resolves and the rebuild finishes, https://folio.YOUR_DOMAIN serves the app, and https://folio.YOUR_DOMAIN/api/curricula hits the backend transparently with one domain, one certificate, and no cross-origin requests at all.
Note: One thing that looks like a failure but is not: for the first minute or so after you add the domain, the browser may refuse to connect, and if you check the certificate it will say
TRAEFIK DEFAULT CERT. That just means Let's Encrypt has not finished issuing yet. Give it a minute and reload, it sorts itself out.
A few errors came up while building this. Here are the ones worth knowing about, and how the troubleshooting went.
You are trying to clone an SSH repository without an SSH keyI tried the plain Git/SSH deploy-key route first. The GitHub org had a policy that disables deploy keys altogether, which shows up on GitHub as "Disabled by org policy" under repo Settings → Deploy keys.
Fix: use Dokploy's GitHub App integration (Settings → Git → GitHub) rather than SSH deploy keys when an org blocks them.
ReferenceError: CustomEvent is not definedNixpacks picked Node 18 to build the frontend, and the Vite version Folio uses needs Node 20.19 or 22.12 and above. Node 18 does not have CustomEvent as a global, which is what the error is really complaining about.
Setting NIXPACKS_NODE_VERSION does not get you out of this one. Nixpacks builds from a pinned set of packages, and the newest Node in it is 22.11, one release short of what Vite asks for. Asking for Node 20 gives you 20.18, which is also just short. When the version is close but still too low, the build fails in a more confusing way than it should: npm quietly skips the platform binary Vite needs because it does not match the Node version, and the build dies further down with a missing module error instead of a version error.
Fix: use Dockerfile as the Build Type, with a base image you pick yourself such as node:22-alpine.
open Dockerfile: no such file or directoryBuild Path pointed at the wrong folder, either too shallow (repo root when the Dockerfile is actually in a subfolder) or too deep (a subfolder, like /src, that does not contain the Dockerfile at all).
Fix: Build Path must match exactly where the Dockerfile you want sits in the repo, not the folder containing your source code, if those differ. Folio's frontend Dockerfile lives at the repo root while the Vue source lives in src/, so Build Path is /, not /src.
The container built fine, but Traefik could not reach it. Two distinct causes came up:
PORT is set to. If the Domains tab's Port field does not match, Traefik proxies to nothing.Exited states is crashing on startup, so this is not a routing problem. One thing to know before you run any of these commands: Dokploy does not name the container after your service. It generates a name like app-back-up-redundant-card-37v4g9, which you will find on the service's General tab, and that generated name is what the docker commands want.# Check the state
docker service ps THAT_NAME
# Get the real logs
docker service logs --tail 50 THAT_NAME
docker logs CONTAINER_ID here, crashed containers get cleaned up too fast to catch by ID.MongooseError: The uri parameter to openUri() must be a string, got "undefined"MONGO_URI was never set as a real environment variable on the service. Variables do not carry over between services, or from an old server to a new one, and a committed .env file is not a substitute. PORT goes the same way. When PORT is the one missing you get Server running on port undefined instead, which is the more annoying of the two: Node binds to some random free port rather than failing outright, so the container looks fine for a while and then starts getting killed for failing its health check.
Fix: add both MONGO_URI and PORT under the service's Environment tab, then redeploy so the values are actually injected.
The connection string Atlas hands you in its Connect dialog does not include a database name. It ends with mongodb.net/?appName=... with nothing between the slash and the question mark. Mongoose reads that as "use the default database", so the API connects without complaining, returns empty lists, and writes anything new into a database you are not looking at.
Fix: put the database name in the path yourself, so it reads folio1.wts1uh2.mongodb.net/folio?retryWrites=true&w=majority. The rest of the string stays exactly as Atlas gave it to you.
Error: Domain resolves to OLD_IP but should point to NEW_IPThis came up after recreating the Compute for a clean test. Any record still pointing at the old server keeps resolving to a machine that is gone.
Fix: update the A record at your DNS provider to the new IP. Dokploy can tell you the two do not match, but it cannot fix it, since DNS lives outside Dokploy entirely.
net::ERR_CERT_AUTHORITY_INVALIDThis looked like Let's Encrypt failing to issue a certificate. Two things were actually going on:
The Network tab showed API requests going somewhere that was not the backend at all. Two things were behind it. The variable had been typed as VITE_API_BASE, while the code actually reads VITE_API_BASE_URL. Then, once that was corrected, several rebuilds still changed nothing, because the value had been set in the Environment tab, which never reaches the build. Step 5 explains why that is.
Fix:
ARG VITE_API_BASE_URL and ENV VITE_API_BASE_URL=$VITE_API_BASE_URL lines in the frontend Dockerfile back in Step 5.Congratulations on your deployment! One thing worth keeping in mind is that every codebase is laid out differently. Folio has its own structure, and developers all have their own preferences. So before you deploy on any of our images, take the time to understand your own codebase and work out where the files Dokploy needs actually live.
If you run into any problems with our platform, reach out for support at cloud@strettch.com.