Astro, Svelte, and Docker Compose
Learning objectives
- You know how to configure an Astro application as a part of a larger Docker Compose project.
Let's again continue on our items api that we've been working on at multiple locations in the materials. The structure of the project is currently as follows.
tree --dirsfirst
.
├── flyway
│ └── sql
│ └── V1___initial_schema.sql
├── items-api
│ ├── services
│ │ └── itemService.js
│ ├── util
│ │ └── cacheUtil.js
│ ├── app.js
│ ├── deps.js
│ └── Dockerfile
├── nginx
│ └── nginx.conf
├── redis
│ └── redis.conf
├── docker-compose.yml
└── project.env
Let's add an UI to it.
Creating an Astro project and adding Svelte
First, in the root folder of the project, run npm create astro@latest
. When the generator asks for the name of the project, select items-ui
. Otherwise, choose options that you prefer (or follow the steps in Introduction to Astro and tooling).
Once done, there is also a folder items-ui
. The project structure is now as follows (omitting the node_modules
folder in items-ui
and a potential app-cache
).
tree --dirsfirst
.
├── flyway
│ └── sql
│ └── V1___initial_schema.sql
├── items-api
│ ├── services
│ │ └── itemService.js
│ ├── util
│ │ └── cacheUtil.js
│ ├── app.js
│ ├── deps.js
│ └── Dockerfile
├── items-ui
│ ├── public
│ │ └── favicon.svg
│ ├── src
│ │ ├── pages
│ │ │ └── index.astro
│ │ └── env.d.ts
│ ├── astro.config.mjs
│ ├── package.json
│ ├── package-lock.json
│ ├── README.md
│ └── tsconfig.json
├── nginx
│ └── nginx.conf
├── redis
│ └── redis.conf
├── docker-compose.yml
└── project.env
Next, in the items-ui
folder, run the command npx astro add svelte
to add Svelte to the project. Allow creating the svelte config (svelte.config.js
) and adjusting the Astro configuration (astro.config.mjs
).
Verify that the application in items-ui
starts up by running npm run dev
. If the application does not start up, ask for help.
Creating a Dockerfile and setting up Docker Compose
For running Astro within Docker using Docker Compose, we need to first create a Dockerfile
for it. Create a Dockerfile
to the folder items-ui
and copy the following contents to it. In essence, the Dockerfile retrieves a small Linux-based image (using Alpine Linux) with NodeJs. This is followed by exposing the port 3000
, setting a few options, copying configuration files to the image, and running the npm install
command that installs the dependencies to the image. The rest of the project is copied to the image after this, and finally the development server is started.
FROM node:lts-alpine3.17
EXPOSE 3000
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
COPY *.json ./
RUN npm install
COPY . .
CMD [ "astro", "dev" ]
For this to work, we need to configure Astro to run on port 3000
in host mode (effectively listening on all addresses). Adjust the astro.config.mjs
to match the following.
import { defineConfig } from "astro/config";
import svelte from "@astrojs/svelte";
export default defineConfig({
integrations: [svelte()],
server: { port: 3000, host: true },
});
Next, create a new service in the docker-compose.yml
file. Let's call the service items-ui
. In the first version, we map the contents from the folder items-ui
to the folder app
within the container, create a separate volume that maps a folder called astro_node_modules
to the folder /app/node_modules
within the container, and start the application so that it exposes the port 3000
to the world.
// ...
items-ui:
build: items-ui
image: items-ui
restart: "no"
volumes:
- ./items-ui/:/app
- astro_node_modules:/app/node_modules
ports:
- "3000:3000"
depends_on:
- items-api
// ...
In addition, before the definition of the services (and after the definition of the version of the docker compose file), define a volume called astro_node_modules
as follows.
version: "3.4"
volumes:
astro_node_modules:
services:
// ...
The volume astro_node_modules
is created as Astro (and some other frameworks) use Vite and esbuild for pre-bundling dependencies. When used with Docker that launches a Linux-based image while working on a Windows / MacOS machine, the esbuild version in the local node_modules
folder of the operating system (i.e. Win/Mac) will not work in the Linux-based container. To get around this, we create a separate volume and configure the docker-compose.yml
file so that it uses a volume for the node_modules
folder. This is done for the purposes of development -- in production, the configuration will be different, as discussed later.
Next, try running the docker compose up --build
command. If things work as expected, you will also see an output similar to the following somewhere in the logs.
items-ui_1 | 🚀 astro (version) started in 83ms
items-ui_1 |
items-ui_1 | ┃ Local http://localhost:3000/
items-ui_1 | ┃ Network http://172.21.0.6:3000/
Now, when visiting localhost:3000
, we see the message "Astro", as shown in Figure 1.

At the same time, when we visit the path localhost:7800
, which is controlled by NGINX, we see a message from our items-api
. Let's change this next.
Configuring NGINX
Ideally, user interfaces are available in the root of an application, while APIs are exposed in other paths. Let's adjust our NGINX configuration so that requests to /api
are directed to the items-api
service, while requests to /
are directed to the items-ui
service.
For this, we define a new upstream mapping, linking the upstream items-ui
to a server items-ui
at port 3000
. In addition, we adjust the server configuration so that requests to location /api
are mapped to the upstream items-api
, while requests to the root path of the application -- /
-- are mapped to the upstream items-ui
.
worker_processes 1;
events {
worker_connections 1024;
}
http {
upstream items-api {
server items-api:7777;
}
upstream items-ui {
server items-ui:3000;
}
server {
listen 7800;
location /api {
proxy_pass http://items-api;
}
location / {
proxy_pass http://items-ui;
}
}
}
Now, when we restart the application, we observe that requests to http://localhost:7800
display our UI, as shown in Figure 2.

However, when we access the location http://localhost:7800/api/
, the response is not as expected. We receive a message stating Not found
, as shown in Figure 3.

Minor things matter.
In our above configuration, we've missed a minor detail, a slash from the end of the proxy_pass
address. With the following configuration, requests made to the address http://localhost:7800/api
are sent to the path http://items-api/api
(i.e. http://localhost:7777/api
) on the server.
location /api {
proxy_pass http://items-api;
}
If we add a trailing slash, the situation changes a bit. With the following configuration, requests made to the address http://localhost:7800/api
are sent to the path http://items-api/
(i.e. http://localhost:7777/
) on the server.
worker_processes 1;
events {
worker_connections 1024;
}
http {
upstream items-api {
server items-api:7777;
}
upstream items-ui {
server items-ui:3000;
}
server {
listen 7800;
location /api/ {
proxy_pass http://items-api/;
}
location / {
proxy_pass http://items-ui/;
}
}
}
Adjust the NGINX configuration to match the following, and restart the project. Now, when we make a request to http://localhost:7800
, we see the response from Astro. Similarly, the Items API is available at http://localhost:7800/api
-- e.g., a request to http://localhost:7800/api/items
produces the following result.
curl localhost:7800/api/items
[]%
Adjusting Docker Compose file
Now that the API is available over NGINX, we can hide the port 3000
from the world, directing traffic to the items-ui
service through NGINX. Modify the items-ui
service so that it uses expose
in port definition instead of ports
-- that is, modify the configuration as follows.
// ...
items-ui:
build: items-ui
image: items-ui
restart: "no"
volumes:
- ./items-ui/:/app
- astro_node_modules:/app/node_modules
expose:
- 3000
depends_on:
- items-api
// ...
Now, after restarting the application, the Astro application is no longer available through the port 3000
, but can be accessed over NGINX.
curl localhost:3000
curl: (7) Failed to connect to localhost port 3000: Connection refused
curl localhost:7800
// ... content
Re-configuring NGINX
After modifying the application so that the user interface is no longer directly available on the port 3000
and restarting the application, we observe that the functionality that is used to update user interface when we save changes locally no longer works. When we open up the browser console, we see an error as shown in Figure 4.

The error highlights that a web socket connection to the server fails. To allow web socket connections over NGINX, the NGINX configuration needs to be adjusted. Web sockets -- as discussed in a later chapter on web sockets -- have their own protocol. Establishing a web socket connection is done by upgrading a HTTP connection, realized through passing information in HTTP request headers. Currently, our NGINX configuration does not support this.
To allow upgrading a HTTP connection to a web socket connection, modify the NGINX configuration for location /
, i.e. the items-ui
as follows.
location / {
proxy_pass http://items-ui;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
Once the configuration is modified and we restart the application, the web socket connection again works and the error is no longer visible in the browser console, as shown in Figure 5.

Working with items API
Next, let's add the functionality for working with the items API using Svelte and Astro. Create a folder components
to the src
folder of items-ui
, and create a component Items.svelte
there. Save the following code to the file -- the code fetches content from the API and lists it to the user.
<script>
const getItems = async () => {
const response = await fetch("/api/items");
return await response.json();
};
let itemsPromise = getItems();
</script>
<h1>Items</h1>
{#await itemsPromise}
<p>Loading items</p>
{:then items}
{#if items.length == 0}
<p>No items available</p>
{:else}
<ul>
{#each items as item}
<li>{item.name}</li>
{/each}
</ul>
{/if}
{/await}
Import the component to the index.astro
in the pages
folder under items-ui/src
. To utilize the JavaScript, use the client:only
directive. That is, modify the index.astro
to match the following.
---
import Items from "../components/Items.svelte";
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<Items client:load />
</body>
</html>
Now, when you open up the application in the browser, the view depends on the contents of the database. With an empty database, the view is as shown in Figure 6.

Let's add an item to the application through the command line.
curl "localhost:7800/api/items"
[]%
curl -X POST -d '{"name": "A book"}' "localhost:7800/api/items"
OK%
curl "localhost:7800/api/items"
[{"id":1,"name":"A book"}]%
Now, when we test the application again, we see the item retrieved from the database, as shown in Figure 7.

Let's next add the functionality for adding an item. Modify Items.svelte
so that it has an input field an a button. The input field should be bound to a variable, which can be added to the database. As the first version, let's create the functionality to alert the typed in text.
<script>
let name = "";
const getItems = async () => {
const response = await fetch("/api/items");
return await response.json();
};
let itemsPromise = getItems();
const addItem = async () => {
alert(name);
};
</script>
<h1>Items</h1>
<input type="text" bind:value={name} />
<button on:click={addItem}>Add item</button>
{#await itemsPromise}
// ...
Now, when we open up the application, we see the input field in the application, as shown in Figure 8.

Next, let's modify the function addItem
so that it sends the new item to the API, and updates the list of shown items. This can be done with the Fetch API as follows.
const addItem = async () => {
if (name.length == 0) {
return;
}
const newItem = { name };
await fetch("/api/items", {
method: "POST",
body: JSON.stringify(newItem),
});
name = "";
itemsPromise = getItems();
};
Now, our application also provides the functionality for adding items through the user interface. In Figure 9, a new item -- a pen -- has been added to the application.

Items application structure at this point
At this point, the overall items application structure should be as follows (omitting node_modules
and potentially app_cache
).
tree --dirsfirst
.
├── app-cache
| └── ...
├── flyway
│ └── sql
│ └── V1___initial_schema.sql
├── items-api
│ ├── services
│ │ └── itemService.js
│ ├── util
│ │ └── cacheUtil.js
│ ├── app.js
│ ├── deps.js
│ └── Dockerfile
├── items-ui
│ ├── node_modules
│ | └── ...
│ ├── public
│ │ └── favicon.svg
│ ├── src
│ │ ├── components
│ │ │ └── Items.svelte
│ │ ├── pages
│ │ │ └── index.astro
│ │ └── env.d.ts
│ ├── astro.config.mjs
│ ├── Dockerfile
│ ├── package.json
│ ├── package-lock.json
│ ├── README.md
│ ├── svelte.config.js
│ └── tsconfig.json
├── nginx
│ └── nginx.conf
├── redis
│ └── redis.conf
├── docker-compose.yml
└── project.env
Todo API and UI assignment
Here, you'll continue on the Todo application that you've worked on so far, adding an user interface to it. Accessing this part requires completing assignments from chapters 6, 8, and 9.
This content is restricted based on course progress. Cannot determine the current course progress as the user is not registered (or, it takes a while to load the points). If you have not yet registered, please do so now.