Deploying Astro on CloudPloy
Astro sites can be deployed on CloudPloy in two modes: as a static site (SSG) served directly from Nginx, or as a Node.js SSR application running in a Docker container. This guide covers both approaches, when to use each, and the configuration required for production deployments.
SSG vs SSR: Which to Use
Astro's default output is a fully static site - HTML files generated at build time that require no server-side processing. SSR mode generates pages on demand and requires a running Node.js process. Choose based on your content and requirements:
| Criteria | SSG (Static) | SSR (Node.js) |
|---|---|---|
| Content updates | Requires rebuild + redeploy | Can fetch live data per request |
| Authentication | Handled client-side or via API | Can use server-side sessions/cookies |
| Performance | Fastest (static file serving) | Adds server processing time per request |
| Infrastructure | Just Nginx - no Node.js process | Requires a running Node.js container |
| Use case | Marketing sites, blogs, docs | Dynamic content, personalization, auth |
Most Astro sites are better off as static. Use SSR only when you genuinely need per-request server logic that cannot be handled by client-side JavaScript or a separate API.
Option 1: Static Site Deployment (SSG)
For a static Astro site, the build output is a directory of HTML, CSS, and JavaScript files that Nginx serves directly. This is the simplest and most performant deployment option.
Build Configuration
Ensure your astro.config.mjs uses the static output (this is the default):
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static',
build: {
assets: '_astro',
},
trailingSlash: 'never',
}); Nginx Configuration for Astro Static Sites
Serve the dist/ directory from Nginx. CloudPloy configures this automatically, but the key settings for an Astro static site are:
- Serve
dist/as the document root - Return 404 for missing files (Astro's 404.html handles this)
- Set long cache headers for hashed assets in
_astro/ - Set shorter cache headers for HTML files (they change on deploy)
Deployment Script
#!/bin/bash
# Deploy script - runs in CloudPloy deployment hook
# Install dependencies
npm ci
# Build the static site
npm run build
# Nginx serves from dist/ - no additional steps needed
echo "Build complete. dist/ contains $(find dist -name '*.html' | wc -l) HTML files." Option 2: Node.js SSR Deployment
For SSR mode, Astro runs as a Node.js application. Use the Node.js adapter and deploy it as a containerized application on CloudPloy.
Install the Node.js Adapter
npx astro add node Astro Configuration for SSR
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
server: {
port: parseInt(process.env.PORT) || 4321,
host: true,
},
}); The mode: 'standalone' setting creates a self-contained Node.js server in dist/server/entry.mjs that handles both static assets and SSR pages. Set the port from the PORT environment variable - CloudPloy injects this.
Dockerfile for Astro SSR
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 nodejs && adduser -S -u 1001 -G nodejs nodejs
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
USER nodejs
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"] Environment Variables for SSR
Astro SSR can access environment variables at runtime. Variables prefixed with PUBLIC_ are also exposed to the client:
// src/pages/api/example.ts
const apiKey = import.meta.env.API_KEY; // Server-only
const publicUrl = import.meta.env.PUBLIC_SITE_URL; // Also available in browser Set both types in the CloudPloy environment variables dashboard. The PORT variable is set automatically by CloudPloy - your adapter configuration should read it as shown above.
Hybrid Rendering (Recommended for Most Sites)
Astro's hybrid rendering mode lets you prerender most pages statically while opting specific pages into SSR. This is usually the best architecture - static pages get maximum performance, and only pages that need dynamic behavior use SSR:
// astro.config.mjs
export default defineConfig({
output: 'hybrid', // default is static, opt-in to SSR per page
adapter: node({ mode: 'standalone' }),
}); ---
// src/pages/blog/[slug].astro - prerendered (fast, static)
export const prerender = true;
const { slug } = Astro.params;
// fetches at build time
--- ---
// src/pages/dashboard.astro - SSR (dynamic, authenticated)
export const prerender = false;
const user = await getUserFromCookie(Astro.cookies);
if (!user) return Astro.redirect('/login');
--- Content Management
Git-Based Content
For blogs and documentation, store Markdown files in the repository and use Astro's Content Collections. Content updates require a new deployment, but this keeps content in version control with full history and review workflows:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
heroImage: z.string().optional(),
}),
});
export const collections = { blog }; CMS Integration
For content that updates frequently without code deploys, connect Astro to a headless CMS. The API calls happen at build time for SSG, or per request for SSR:
---
// src/pages/posts/[slug].astro
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = async () => {
const posts = await fetch('https://your-cms.io/api/posts')
.then(r => r.json());
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
};
const { post } = Astro.props;
---
<h1>{post.title}</h1> Image Optimization
Astro's built-in Image component optimizes images at build time. For SSG deployments, processed images are included in the dist/ output. For SSR, images are processed on demand:
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<Image
src={heroImage}
alt="Hero image"
width={1200}
height={630}
format="webp"
quality={85}
/> For remote images, configure allowed domains in astro.config.mjs:
export default defineConfig({
image: {
domains: ['images.unsplash.com', 'cdn.yourdomain.com'],
remotePatterns: [{ protocol: 'https' }],
},
}); API Routes
Astro API routes handle form submissions, webhooks, and backend integrations without needing a separate server. In SSG mode, these run at build time. In SSR mode, they run per request:
// src/pages/api/contact.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const message = data.get('message');
if (!email || !message) {
return new Response(JSON.stringify({ error: 'Missing fields' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
await sendContactEmail(email.toString(), message.toString());
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}; Deployment Checklist
- Decide between SSG, SSR, or hybrid rendering before building
- For SSR: configure the Node.js adapter with
mode: 'standalone' - For SSR: read the port from
process.env.PORTin the adapter config - For SSG: ensure
output: 'static'in astro.config.mjs - Set
PUBLIC_prefix only on variables that should be visible to the browser - Configure
sitein astro.config.mjs for correct sitemap and canonical URLs - Test the production build locally with
npm run build && npm run preview - Verify no broken links or missing assets in the build output
Common Issues
| Problem | Cause | Fix |
|---|---|---|
| SSR: 502 Bad Gateway | Node.js not listening on PORT | Read PORT from env in adapter config |
| Images 404 in production | Remote image domains not whitelisted | Add domains to image.domains config |
| Env vars not available in SSR | Using PUBLIC_ prefix only needed for client | Server-only vars don't need PUBLIC_ prefix |
| Build fails on content types | Schema mismatch in content collections | Check frontmatter matches the schema in config.ts |
| Dynamic routes 404 on refresh | Nginx not configured for SPA-style routing | Configure Nginx fallback for SSR mode |
| Trailing slash redirects loop | Conflicting trailingSlash config and Nginx rules | Set trailingSlash: 'never' in astro.config.mjs |