In this tutorial you will learn how to deploy a full-stack application to fly.io. Here is a screenshot of what the app looks like.
We start by dockerizing the app. That means writing a Dockerfile for both client and server. Then we will write a docker compose file to start up all containers locally. Last, we will deploy to Fly.io.
New to Fly.io? Watch their video Fly.io in 60 seconds.
To dockerize the app we need to create a Dockerfile for the back-end in
server/ folder and for front-end in client/ folder.
Create a new file named Dockerfile in server/ folder.
The folder has couple of subfolders.
One for each C# project that are part of the solution.
We are using a multistage Dockerfile, so the final image only contains what's
needed to run the server.
The dotnet/sdk base images is required to run dotnet publish, but we only
use the smaller dotnet/aspnet image to run the compiled application.
Paths specified in a Dockerfile will be relative to its location.
So when we copy files into the container image, we use a COPY . /source to
copy everything from server/ folder on host into /source within the build
container.
# 1. Create a stage for building the application.
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
# 2. Copy everything in current directory (server/) into /source on build container
COPY . /source
# 3. Change container working directory to /source/api
WORKDIR /source/Api
# 4. Build the application with Release configuration and for linux-x64 since Fly
# runs.
RUN dotnet publish --configuration Release --no-self-contained -o /app
# 5. Create a new stage for running the application with minimal runtime
# dependencies.
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final
# 6. Change container working directory to /app
WORKDIR /app
# 7. Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
# 8. Switch to a non-privileged user (defined in the base image) that the app will run under.
USER $APP_UID
# 9. Describe what port the server will listen on.
EXPOSE 8080
# 10. Start Api
CMD ["dotnet", "Api.dll"]Here is a breakdown of the instructions.
Build stage
- Use
dotnet/sdkas base image. - Copy source code for server from host to
/sourcefolder inside the build container. - Change working directory inside the build container to
/source/Api.- This is where
Program.csandApi.csprojfiles exist
- This is where
- Compile (publish) the application with release configuration.
- The output is stored in
/app
- The output is stored in
Final stage
- Create a new stage named
finalwithdotnet/aspnetas base image. - Change working directory for
finalstage to/app - Copy the compiled files
/appin build stage to current directory (/app) infinalstage. - Change user, so the app won't run as root with
USER $APP_UID. - Tell Docker that it will expose
8080network port. - Run the app with
dotnet Api.dll
You can try it out by running:
docker build serverNote that server refer to the directory containing the Dockerfile to
build.
Moving over to the client.
This project is setup such that client use relative paths for all API calls.
It means that API requests will hit the HTTP server serving the front-end then
it will forward the request to the back-end HTTP server.
When running npm run dev it starts development web-server supporting
hot-reloading.
You can see the configuration for forwarding in client/vite.config.ts.
All great for development, but npm run dev should not be used in production.
For production, we will use nginx instead.
To make everything work, we need a custom config.
Create the file client/nginx.conf.template with:
map $http_connection $connection_upgrade {
"~*Upgrade" $http_connection;
default keep-alive;
}
server {
listen 80;
root /usr/share/nginx/html;
index index.html index.htm;
location /api {
proxy_pass $BACKEND_URL;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_ssl_server_name on;
}
location / {
try_files $uri $uri/ /index.html index.html;
}
}The client (React) app use relative URLs for all request to server.
We therefore have a location /api block which proxy all request for /api to
the URL described in proxy_pass.
We use $BACKEND_URL as a placeholder that will be substituted with an
environment variable.
This allows us to control what URL request will be proxied from an environment
variable on the container.
Create a new file in client/ folder named Dockerfile.
Insert the following:
# 1. Create a stage for building the application.
FROM node:22-alpine AS build
WORKDIR /app
# 2. Copy package.json and package-lock.json
COPY package*.json ./
# 3. Install dependencies
RUN npm clean-install
# Copy the rest of the application source code
COPY . .
# 4. Build the React application
RUN npm run build
# 5. Stage 2: Serve the React app using nginx
FROM nginx:alpine AS final
# 6. Custom nginx config
COPY nginx.conf.template /
# 7. Copy the build output from the first stage to nginx's html directory
COPY --from=build /app/dist /usr/share/nginx/html
# 8. Expose port 80
EXPOSE 80
# 9. Start nginx
CMD envsubst '$BACKEND_URL' < /nginx.conf.template > /etc/nginx/conf.d/default.conf \
&& nginx -g 'daemon off;'We need a base image with node to transpile the code.
But we don't need node for serving the JavaScript files, however we do need
nginx instead.
Perfect candidate for a two stage build!
Here is a quick breakdown of the steps.
Build stage
- Use
node:22-alpineas base image - Copy
package.jsonandpackage-lock.json(notice the*) into build container. - Install dependencies with `npm clean-install.
- Transpile TypeScript to JavaScript with
npm run build
Final stage
- Use
nginx:alpineas base image. - Copy our custom nginx config from host.
- Copy build output from build stage to default folder for nginx.
- Expose port 80 because that is the default port for nginx and HTTP.
- Replace
BACKEND_URLin nginx config and start nginx
The part envsubst '$BACKEND_URL' < /nginx.conf.template > /etc/nginx/conf.d/default.conf needs a bit of extra explanation.
The command envsubst is short for environment variable substitution.
It will substitute placeholders in its input for environment variables in its
output.
/nginx.conf.template is input and /etc/nginx/conf.d/default.conf
Thereby writing a configuration file for nginx when container is run that
includes a proxy URL given by an environment variable to the container.
Try it out by running:
docker build clientWe can combine it all with a Docker Compose file.
Not terribly useful for deploying to Fly.io, but pretty neat for onboarding new
teammates and getting them up and running.
All they need to start the app is clone the repository and type docker compose up.
There is already a compose.yml file in the root of the repository, but
currently it only contains a definition for the database.
Let's add client and server to it.
Change the compose.yml file in the root of the repository to this:
services:
client:
build: client/
ports:
- "80:80"
environment:
- BACKEND_URL=http://server:8080
server:
build: server/
environment:
- ConnectionStrings__AppDb=HOST=db;DB=postgres;UID=postgres;PWD=mysecret;PORT=5432;
ports:
- "8080:8080"
db:
image: postgres:17-alpine
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mysecret
ports:
- "5432:5432"Warning
Never put connection string for your cloud hosted database into compose.yml like this.
It defines 3 services (client, server and db) which can be manged together as a single unit.
For the client container, we use BACKEND_URL to tell how it can talk to
the server container.
And for the server service we set ConnectionStrings__AppDb to tell it how
it can talk to the db container.
Try it out by running:
docker compose up -dWe hope to see 3 containers when running:
docker compose psWait! There is only 2. What happened to the server? Time for some debugging!
You can view the logs with:
docker compose logsThe lines a prefix with name given to the container.
Looks like there is a stack-trace in the output for server.
This indicates that an exception was thrown.
We can "zoom-in" on the log output only for server by typing:
docker compose logs serverSomewhere towards the top, we see:
Npgsql.NpgsqlException (0x80004005): Failed to connect to 172.18.0.4:5432
Npgsql is the database driver we use
in .NET to talk to PostgreSQL and 5432 is the default port for PostgreSQL.
It means that it couldn't connect to the database.
Interesting, since we can see the db container is running but not server.
Tip
The IP address 172.18.0.4 is internal to the virtual network Docker creates
for containers.
It is not accessible from the outside.
What's happening here is that all containers start in parallel. The server starts a bit faster than the database, so it tries to connect before the database is ready, which it can't, so it crashes.
It is possible to add dependencies between containers in a compose file, such that a container is started after the container it depends on.
Add the following to the server service section in compose.yml:
depends_on:
db:
condition: service_healthyHere is a truncated version of what it should look like:
services:
...
server:
build: server/
depends_on:
db:
condition: service_healthy
...It means that server won't be started before db is in a healthy condition.
But what does being healthy mean for a database?
Luckily, they included pg_isready shell command to answer such deep
philosophical question.
I'm joking here.
Being healthy for a database simply means that it is ready to accept incoming
connections and there is a tool to test this in the postgres image.
Under the db section in compose.yml, add:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 1s
timeout: 5s
retries: 5It simply checks the healthy by executing the shell command pg_isready -U postgres where the -U is the username for database the instance.
Since client depends on server, we should also add this dependency to compose.yml.
So, under the client service section, add:
depends_on:
- serverNow if you do:
docker compose restart
docker compose psThen you should see all 3 containers running. You can try it out by opening http://localhost
Here is the full compose.yml for reference, in case something is still wrong.
services:
client:
build: client/
ports:
- "80:80"
depends_on:
- server
environment:
- BACKEND_URL=http://server:8080
server:
build: server/
depends_on:
db:
condition: service_healthy
environment:
- ConnectionStrings__AppDb=HOST=db;DB=postgres;UID=postgres;PWD=mysecret;PORT=5432;
ports:
- "8080:8080"
db:
image: postgres:17-alpine
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mysecret
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 1s
timeout: 5s
retries: 5The app requires a Postgres database to run. It's fine to run a database locally through docker for development, but that won't work when deploying the app.
Here are the options I can think of. Pros and cons are given based on my opinion for small scale production ready hosting.
- Deploy the postgres docker image
- Pros: closely matching our development setup.
- Cons: Complicated to manage security and backup
- Fly managed Postgres
- Pros: Same provider we use for hosting the app
- Cons: Pricey
- NEON serverless Postgres
- Pros: Easy, try for free
- Cons: Runs on different data-centers than Fly.io
I think NEON has the best trade-off for this deployment exercise.
Go to https://neon.com/ and sign-up for a free account. Then create a new project in Neon.
For region, you want to choose something close to you. Frankfurt seems to be the closest to where I'm located, so that is what I choose.
After the project has been created, click on "Connect" as shown.
Change "psql" in the dropdown ".NET". Then click "Copy snippet" and store it somewhere safe. You will need it for later.
Important
This section assumes that you have flyctl installed. See Install flyctl for instructions on how to install it.
The whole compose thing was a bit of a side-quest. Now, back to the main quest-line. Getting our app deployed to fly.io.
Using Fly.io we can deploy from a docker image. Normally the process would be:
- Create a container registry
- Build Dockerfile to image
- Upload image to registry
- Create a new Fly app (like a project on Fly.io)
- Deploy image from registry to the app
That is how you deploy container images on most competitors.
However, fly.io conveniently wraps it all up in a single fly launch command.
Let's try it out!
cd server
fly launch --vm-size=shared-cpu-1x
fly scale count 1Hit "enter" to all questions.
Here's a breakdown.
- Change directory to where the Dockerfile we want to deploy is located.
- Launch using the smallest VM size possible.
- Scale the number of instances to 1.
When launching an app, it creates 2 VMs which is great for availability. Of one crashes then all traffic goes to the other while the first restarts. However, we are just playing around deployment, so we don't care about availability, instead we try to keep the cost as low as possible by scaling it down to a single instance.
We can check the status of the deployment with:
fly statusOh no, looks like it has crashed. Let's check the logs to see what is going on.
fly logsAt the top of the stack-trace see Host can't be null and something about
Npgsql.
It's because we haven't configured the connection-string yet.
The connection-string contains password for the database instance, so it should be treated as a secret.
We can use the fly secrets set command to set a secret.
Secrets are made available to the app as environment variables.
You can read Secrets and Fly Apps to
learn more.
In .NET we can use environment variables to override configurations in
appsettings.json.
Read Tools and Best Practices for Secret Management in
.NET to learn more.
From a terminal in server/ folder, use fly secrets set ConnectionStrings__AppDb='<connection-string> to set the connection-string,
where <connection-string> is replaced with the snippet you
copied from Neon.
It should look something like this:
fly secrets set ConnectionStrings__AppDb='Host=******************************.eu-central-1.aws.neon.tech; Database=neondb; Username=neondb_owner; Password=****************; SSL Mode=VerifyFull; Channel Binding=Require;'Important
Notice that single-quotes ' is used instead of double-quotes "
The app should redeploy after setting the secret. You can check logs again to make sure it works.
fly logsOne last adjustment for the server, is to open server/fly.toml and change
force_https to false .
We can now deploy the client similar to what we did with the server.
cd client
fly launch --vm-size=shared-cpu-1x
fly scale count 1Hit "enter" to all questions.
You can open the page in a web-browser by typing:
fly openWe haven't set the BACKEND environment variable yet, so you get an error.
To fix it, set the variable in client/fly.toml by appending this:
[env]
BACKEND_URL = "https://<backend-name>.fly.dev"Replace <backend-name> with name of the server app, which you can find by
running:
fly apps listThen redeploy with:
cd client
fly deployTry it out in your web-browser. An easy way to open the URL is to type:
fly openTo avoid potential being billing we better clean up again after ourselves by removing the fly apps again.
To show the fly apps you have run:
fly apps listDestroy the apps you created as part of this guide.
fly apps destroy <your-client-app-name>
fly apps destroy <your-server-app-name>Where <your-client-app-name> and <your-server-app-name> are names from fly apps list.
Go to https://console.neon.tech/ then setting for "tutorial-deploy-flyio" project.
All the way down at the bottom of the page, click "Delete project" button.



