Migrating my Astro site from Cloudflare Pages to a VPS
Published on
My website is was currently hosted through Cloudflare pages but I’ve moved it over to my own server so that I have full ownership of it (and because I’d rather use Node over Wrangler for some features that I want to add in the future). Previously, my website dev workflow was:
- Push changes to a repo that is hosted on my own Gitea instance
- Gitea then mirrors the repo to my GitHub account
- GitHub deploys to Cloudflare pages
To get rid of these two dependencies, I needed to set up a CI/CD process so that when I push to Gitea, it will trigger a build on my VPS.
To do this, I needed to:
- Create a Dockerfile and two Docker Compose files for
devandproductionenvironments. - Build/run the Docker containers and expose them to the internet with a reverse proxy.
- Set up a deploy user and SSH keys for authenticating during the deployment workflow.
- Get each step working in Gitea Actions for seamless deployment. Note: I did use AI (Gemma 4 26b in Ollama/LM Studio and Claude) to help with troubleshooting errors around SSH authentication. Note 2: If you’re planning on hosting your site/project on your own VPS where you’re also hosting Gitea, there are some gotchas (link to gotchas heading). Note 3: I’m using Pangolin as my reverse proxy and I won’t be including setting the project up in Pangolin’s backend in this post. If you’re using another reverse proxy, you may need to tweak the code examples.
VPS setup
On my VPS, I have Gitea and Act Runner running in Docker containers which run through Pangolin as the reverse proxy. As my domain is set up through Cloudflare’s DNS, I had to set up another wildcard domain in Pangolin to use subdomains as well as utilise Cloudflare’s SSL certs and proxy.
Astro adapter change
As I want to move off of Cloudflare Pages, I can’t use their Astro adapter anymore. I wasn’t using any of Cloudflare’s features (workers, D1, etc) so it was easy to switch to the Node adapter.
// astro.config.js
// From
import { defineConfig } from 'astro/config';
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
site: "https://tommyoldfield.co.uk",
output: "static",
adapter: cloudflare(),
});
// To
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
site: "https://tommyoldfield.co.uk",
output: "static",
adapter: node({
mode: 'standalone'
})
});
Docker
While I have plenty of experience setting up and using docker-compose.yml files for setting up stacks and containers, I’ve only created custom Dockerfiles a couple of times. I started with a Dockerfile that I had used for a previous Astro project and with some help from Gemma 4, got a working Dockerfile that works for dev and prod environments with accompanying Docker compose files.
FROM node:lts-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS build
RUN npm install
COPY . .
RUN npm run build-no-ts
FROM node:lts-alpine AS runtime
WORKDIR /app
# Copy only the built output from the build stage
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm install --omit=dev
# Backup port if one isn't set in docker compose
ARG PORT=1234
ENV HOST=0.0.0.0
ENV PORT=$PORT
EXPOSE $PORT
CMD ["node", "./dist/server/entry.mjs"]
Using the alpine versions of the Node images makes the container size small and resource efficient.
Docker compose
With the Dockerfile doing most of the heavy lifting, all that’s needed in the two compose files (dev and prod) are the container names, ports, and network so that Pangolin can do the reverse proxying. All that needs to change for both compose files are the container name and port.
services:
astro-app:
container_name: portfolio-prod # or -dev
build:
context: .
target: runtime
args:
PORT: 1234
ports:
- "1234:1234" # or whatever port you'd like to use
restart: always
networks:
- pangolin
networks:
pangolin:
external: true
The pangolin network is there so that the container can be used within Pangolin and set up with a domain/subdomain. If you’re using a different reverse proxy, your networks value will be different.
CI/CD
This is the part where I struggled (mainly around getting the deployment working via SSH) and relied on going back and forth with Claude to ask questions and generate the appropriate commands.
While I understand how the Gitea runner works and what the commands do, it was really helpful to have a tool to explain concepts and commands, answer my questions, and provide alternative solutions when I flagged potential issues and errors.
For example, when I asked how I would add deployment via SSH, Claude’s first response was to use appleboy/ssh_action. I took a look at the repo as I’d not heard of it before and saw an issue about how ssh_action had used a compromised dependency in its own CI workflow. Seeing ‘compromised’ made me wary so I asked Claude about it and if there was a different way of deploying via SSH. It explained that it was not a security issue in the code itself and was still safe to use but generated an alternative solution using the native ssh Linux commands.
Although this process still took a handful of nights to figure out and get working, it would’ve taken much longer if I had relied on trying to convert my errors with context into questions, putting them into Google and trawling Reddit threads, Stack Overflow answers, and blog posts for answers.
Deployment setup
With all that said, here’s the initial process for setting this up with Gitea (although this should also work for GitHub Actions too):
- Add a label to your runner. For Astro, I’ve set mine to
node-24. - Create a new user on the VPS specifically for deployment and make sure it has the correct permissions for the directory you’ll be pulling code to.
- Generate an SSH key for the user but don’t generate the passphrase for it!
- Copy your public key (by running
cat ~/.ssh/id_yourkeynamehere.pubin your VPS) and put it in a Gitea Actions secret. - Create an SSH key for Gitea so that you can clone/pull via SSH (again, no passphrase!).
- Clone your project with the SSH URL inside of your VPS.
- Set up secrets for your VPS username, IP address, and the directory where your project lives.
Setting up the workflow
Once you have everything set up you can create your workflow .yml file(s). For my website I set up two: dev and prod. All that’s different between them is the branch, the VPS directory, and the docker compose file to be run.
name: Dev deployment
run-name: Deploying to dev
on:
push:
branches:
- dev
jobs:
deploy:
runs-on: node-24
steps:
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOYER_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_yourkeynamehere
chmod 600 ~/.ssh/id_yourkeynamehere
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} << EOF
cd ${{ vars.DEV_DIRECTORY }}
git pull origin dev
docker compose -f docker-compose-dev.yaml up -d --build --force-recreate
EOF
Here’s what happens:
- The
deployjob runs when you push to thedevbranch. - The runner SSH’s into your server and makes its way to your project’s directory.
- It pulls in the updated code and then builds and runs your docker compose file.
- Docker builds your project and runs
node ./dist/server/entry.mjsto output your project on your port of choice. --force-recreatewill stop any current project containers and start a new one.- It get’s deployed! 🚀
Gotchas
Because my Gitea instance was hosted on the same VPS that I was going to be hosting my website on, I had errors around creating the SSH key for Gitea and pulling the updated code into the relevant deployment directory.
When pulling changes before running the workflow with the SSH url (e.g. [email protected]:username/repo.git) it was asking me for a password, which shouldn’t happen as it should be using your Gitea SSH key instead.
How I fixed it was by editing the ~/.ssh/config file and creating a new alias for my Gitea instance. You need to use the same hostname and port that your Gitea instance uses. If your Gitea docker compose file’s port is just "5432:5432" you can remove the HostName line, but if your port is set up like "127.0.0.1:5000:5000" to keep Gitea on the local network, you need to keep it in.
Host gitea-local
HostName 127.0.0.1
Port 8362 # Put your Gitea port here
IdentityFile ~/.ssh/id_yourGiteaSshKey
I was then able to set the remote of my repo with git remote set-url origin git@gitea-local:user/repo.git.
Conclusion
Although this was challenging to set up with lots of technical hurdles thrown-in, I’m really glad to have this set up and have the website fully under my ownership. The downsides to having it on a VPS are that I need to maintain and fix any issues that occur with the CI/CD process and that I will need to update the VPS and Pangolin which will bring the site down for a couple of minutes. This isn’t much of a big deal but still some added overhead for me.
Going back and forth with LLMs for troubleshooting errors for this project was really helpful and I think I would have struggled without it, especially for the Gitea SSH key gotcha. I’ve learnt to be more direct with my prompts and give it more relevant context rather than asking a general question and add context in afterwards.