Most Angular tutorials stop at ng serve. You build a feature-complete SPA with a forms module, HTTP interceptors, lazy-loaded routes, then realise you need somewhere to store the data. You spin up a local MySQL container, wire in a Node.js Express middleware layer, and now you have three moving parts to deploy.
The common failure mode: you push the Angular build to a static host, deploy the API to a different service, provision a database on a third platform, then spend an afternoon debugging CORS and connection strings. Here's the single-platform path — Angular frontend, Express API, and a managed MySQL instance, all wired together with environment variables that flow from the database through to the compiled bundle.
What you'll build
- An Angular 18 SPA served from a CDN (zero idle cost, instant cold starts)
- A Node.js Express API deployed as a container, connecting to MySQL
- A managed MySQL 8 instance (daily backups, automatic SSL)
- Environment variables that inject
API_URLinto the Angular build andDATABASE_URLinto the Express runtime
Provision the MySQL database first
Databases take longer to spin up than apps, so start there. You need a running MySQL instance before the API container can connect.
curl -X POST https://api.pandastack.io/v1/databases \
-H "Authorization: Bearer $PANDASTACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "angular-todos-db",
"engine": "mysql",
"version": "8.0"
}'The response includes { "success": true, "data": { "databaseId": "db_abc123", "connectionString": "mysql://..." } }. Save the connectionString — it's a full DSN with credentials, SSL mode enabled by default. You'll pass it to the API as DATABASE_URL.
Free-tier databases get a small storage volume (suitable for dev and hobby projects); for production traffic, a paid plan increases storage, connection limits (50 → 300 → 1000), and backup retention (7 → 15 → 30 days).
Deploy the Express API layer
The Angular app will call this API over HTTP. It's a standard Express server with mysql2 connecting to the managed instance.
Create api/index.js:
const express = require('express');
const mysql = require('mysql2/promise');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
const pool = mysql.createPool(process.env.DATABASE_URL);
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/api/todos', async (req, res) => {
const [rows] = await pool.query('SELECT * FROM todos ORDER BY created_at DESC');
res.json(rows);
});
app.post('/api/todos', async (req, res) => {
const { title } = req.body;
const [result] = await pool.query('INSERT INTO todos (title) VALUES (?)', [title]);
res.json({ id: result.insertId, title });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
console.log(`API listening on ${PORT}`);
});The 0.0.0.0 bind is critical. Binding to localhost or 127.0.0.1 makes the container unreachable from the ingress — health checks fail, the pod never becomes ready, and your deployment shows "Running" in the dashboard but returns 502 errors. Always bind to 0.0.0.0 or the PORT environment variable for containerised apps.
Add a package.json with a start script and deploy:
curl -X POST https://api.pandastack.io/v1/projects \
-H "Authorization: Bearer $PANDASTACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "container",
"name": "angular-api",
"repositoryName": "yourorg/angular-todos",
"branch": "main",
"autoDeploy": true,
"rootDir": "api",
"env": [
{ "name": "DATABASE_URL", "value": "mysql://user:pass@host:3306/db?ssl-mode=REQUIRED" },
{ "name": "PORT", "value": "3000" }
]
}'The rootDir: "api" parameter tells the build system to treat api/ as the repo root — it runs npm install and npm start from that subdirectory. If your API and Angular app live in the same monorepo, this is how you deploy just the API.
The deploy returns a live URL: https://angular-api-abc123.pandastack.app. Save it for the next step.
Deploy the Angular SPA
Angular's production build emits static HTML, CSS, and JavaScript into dist/. You serve that from a CDN; there's no Node.js runtime at request time.
The app needs to know where the API lives. You inject that at build time as an environment variable.
In src/environments/environment.prod.ts:
export const environment = {
production: true,
apiUrl: '%%API_URL%%'
};Add a build-time replacement script in package.json:
{
"scripts": {
"build": "ng build --configuration production && node replace-env.js"
}
}replace-env.js:
const fs = require('fs');
const path = require('path');
const distPath = path.join(__dirname, 'dist/angular-todos/browser');
const files = fs.readdirSync(distPath).filter(f => f.startsWith('main.') && f.endsWith('.js'));
files.forEach(file => {
const filePath = path.join(distPath, file);
let content = fs.readFileSync(filePath, 'utf8');
content = content.replace(/%%API_URL%%/g, process.env.API_URL || '');
fs.writeFileSync(filePath, content);
});Now deploy the frontend:
curl -X POST https://api.pandastack.io/v1/projects \
-H "Authorization: Bearer $PANDASTACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "static",
"name": "angular-todos-ui",
"repositoryName": "yourorg/angular-todos",
"branch": "main",
"autoDeploy": true,
"buildCommand": "npm run build",
"outputDir": "dist/angular-todos/browser",
"env": [
{ "name": "API_URL", "value": "https://angular-api-abc123.pandastack.app" }
]
}'The build runs npm run build, which compiles the Angular app and replaces %%API_URL%% with the real API endpoint. The outputDir points at the compiled assets; those get uploaded to a CDN and served with aggressive caching (hashed assets are immutable, index.html gets revalidated on every deploy).
What happens at request time
A user visits https://angular-todos-ui-xyz.pandastack.app. The CDN serves index.html (edge-cached), which loads the hashed JS bundles (cached for a year). The Angular app boots, reads environment.apiUrl, and makes an HTTP call to the Express API. The API queries MySQL over a TLS connection (the ssl-mode=REQUIRED in DATABASE_URL enforces this), returns JSON, and Angular renders the UI.
The static site has zero idle cost — no pod runs between requests. The API container scales to zero after inactivity on the free tier (sub-second cold start when traffic returns). The MySQL instance runs continuously; you pay for allocated storage, not query volume.
Using pandastack.json instead of the API
If you're building a template repo or a starter kit, a deploy button is friendlier than cURL commands. Add pandastack.json to the repo root:
{
"projects": [
{
"type": "container",
"name": "angular-api",
"rootDir": "api",
"env": [
{ "key": "DATABASE_URL", "description": "MySQL connection string from a managed database" },
{ "key": "PORT", "value": "3000" }
]
},
{
"type": "static",
"name": "angular-todos-ui",
"buildCommand": "npm run build",
"outputDir": "dist/angular-todos/browser",
"env": [
{ "key": "API_URL", "description": "Full URL of the deployed Express API" }
]
}
]
}Then add a deploy button to the README:
[](https://dashboard.pandastack.io/deploy?repo=yourorg/angular-todos)When a user clicks the button, the deploy screen reads pandastack.json and prompts for DATABASE_URL and API_URL before starting the build. No hard-coded secrets in the repo, no editing environment variables in a dashboard after the fact.
Debugging CORS errors
If the Angular app can't reach the API (network tab shows a preflight failure), check two things:
- 1The Express API includes
app.use(cors())— without it, browsers block cross-origin requests. - 2The
API_URLin the Angular build matches the deployed API domain exactly, including thehttps://scheme.
A mismatch (e.g. http:// in API_URL but the API serves over https://) triggers a mixed-content block. Use the browser console network tab to see the actual request URL.
Migrating schema changes
When you add a column or table, run the migration before deploying the new API code. The cleanest path:
- 1SSH into the API container or run a one-off edge function with the migration SQL.
- 2Deploy the updated API code.
- 3The new code expects the new schema; the migration ran first, so no runtime errors.
A common mistake is deploying code and schema simultaneously in a rolling update — half the pods run the old schema, half expect the new one, and you get transient 500 errors during the rollout. Always apply schema changes before code changes.
What you've shipped
Three pieces deployed independently, wired together with environment variables: an Angular SPA that compiles API_URL into the bundle at build time, a Node.js API that reads DATABASE_URL at runtime, and a managed MySQL instance with automatic backups and SSL. The SPA has zero idle cost, the API scales to zero, and the database stays warm.
PandaStack handles SSL certificates, health checks, and ingress routing. Add a custom domain through the dashboard, and the platform provisions a cert automatically. No NGINX configs, no LoadBalancer YAML, no manually renewing Let's Encrypt certs.
References
- [Angular deployment guide](https://angular.io/guide/deployment)
- [Express best practices](https://expressjs.com/en/advanced/best-practice-performance.html)
- [MySQL connection strings](https://dev.mysql.com/doc/refman/8.0/en/connecting-using-uri-or-key-value-pairs.html)
- [PandaStack container projects](https://docs.pandastack.io/projects/containers/)
- [PandaStack static sites](https://docs.pandastack.io/projects/static/)