Andrew BekhietDeploy a Dart App on Railway, Part 2
On our previous article, we have successfully deployed a hello world Dart app to Railway using Serverpod
In this article, we’re going to actually build our small project: Trivyal, so let’s jump right in …
Before anything,
- Create a new Google Cloud Project
- Setup the OAuth consent screen
Please note that I changed the redirectUri from /googlesignIn in Serverpod’s article to /googleSignIn in this article (to save you hours from debugging a spelling error bug)
After finishing OAuth consent screen setup do the following:
- Go to Credentials and click “Create credential” ⇒ “OAuth Client ID”
- Under “Application type” choose “Web application”
- Give the client a name and leave Authorized JavaScript origins and authorized redirect URIs blank for now
- Click “Create” then “Download JSON” and save it to
trivyal_server/config/google_client_secret.json - Make sure to add
config/google_client_secret.jsonto your.gitignoreso you don’t accidentally add it to git
We will be using serverpod_auth_server module to setup authentication in our server
- Add
serverpod_auth_serverto server dependencies andserverpod_auth_clientto client dependencies - Make sure that all related serverpod dependencies have the exact same version across the project to avoid conflicts
- Edit
trivyal_server/lib/server.dartto match the following:
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_server/serverpod_auth_server.dart' as auth; // Added!
import 'package:trivyal_server/src/web/routes/root.dart';
import 'src/generated/endpoints.dart';
import 'src/generated/protocol.dart';
// This is the starting point of your Serverpod server. In most cases, you will
// only need to make additions to this file if you add future calls, are
// configuring Relic (Serverpod's web-server), or need custom setup work.
void run(List<String> args) async {
// Initialize Serverpod and connect it with your generated code.
final pod = Serverpod(
args,
Protocol(),
Endpoints(),
authenticationHandler: auth.authenticationHandler, // Added!
);
pod.webServer.addRoute(auth.RouteGoogleSignIn(), '/googleSignin'); // Added!
// If you are using any future calls, they need to be registered here.
// pod.registerFutureCall(ExampleFutureCall(), 'exampleFutureCall');
// If you are using any future calls, they need to be registered here.
// pod.registerFutureCall(ExampleFutureCall(), 'exampleFutureCall');
// Serve all files in the trivyal_server/web directory.
// This directory will be populated by Docker with our flutter web build.
pod.webServer.addRoute(
RouteStaticDirectory(
serverDirectory: '.',
serveAsRootPath: '/index.html',
basePath: '/',
),
'/*',
);
// Start the server.
await pod.start();
}- Run
serverpod create-migrationto create a new database migration to be applied later
Let’s work on our Flutter app!
On the credentials page in GCP, edit the server OAuth client we created earlier
- Add the following as as Authorized JavaScript origins
http://localhost:8082http://localhost:49660https://<railway-provided-domain>- Add the following as authorized redirect URIs
http://localhost:8082http://localhost:49660http://localhost:8082/googleSignInhttps://<railway-provided-domain>/googleSignIn

Adding origins and redirect URIs in GCP for OAuth
- Save the updated JSON to
trivyal_server/config/google_client_secret.json
We will use flutter_riverpod package for dependency injection in this tutorial, but you can use any other package
- Add
flutter_riverpodtotrivyal_flutterdependencies - Add
serverpod_auth_google_fluttertotrivyal_flutterdependencies. We will useSignInWithGoogleButtonfrom this package to handle the sign in logic - Add
trivyal_flutter/lib/utils/providers.dartfile with the following code:
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:serverpod_auth_google_flutter/serverpod_auth_google_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:trivyal_client/trivyal_client.dart';
enum AppEnvironment { dev, prod }
final Provider<AppEnvironment> appEnvironmentProvider = Provider<AppEnvironment>((ref) => kDebugMode ? AppEnvironment.dev : AppEnvironment.prod);
final Provider<String> serverUrlProvider = Provider<String>(
(ref) => ref.watch(appEnvironmentProvider) == AppEnvironment.dev
? 'http://$localhost:8080/'
: 'https://example.com/api/',
);
final Provider<String> serverWebServerUrlProvider = Provider<String>(
(ref) => ref.watch(appEnvironmentProvider) == AppEnvironment.dev
? 'http://$localhost:8082/'
: 'https://example.com/',
);
final Provider<Client> clientProvider = Provider<Client>(
(ref) => Client(ref.watch(serverUrlProvider), authenticationKeyManager: FlutterAuthenticationKeyManager())..connectivityMonitor = FlutterConnectivityMonitor(),
);
final ChangeNotifierProvider<SessionManager> sessionManagerProvider =
ChangeNotifierProvider<SessionManager>(
(ref) {
final sessionManager = SessionManager(caller: ref.watch(clientProvider).modules.auth);
ref.onDispose(sessionManager.dispose);
return sessionManager;
},
);
Make sure to replace https://example.com with your Railway provided domain for the server
Notice that I moved the Serverpod client singleton to providers and added authenticationKeyManager to store authentication keys and tokens
In trivyal_flutter/lib/main.dart
- Add a
ProviderScopewidget to the root of the app, and ensure the session manager is initialized. Then we can use the session manager to determine if the user is signed in or not and redirect toLoginScreenorHomeScreen
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:serverpod_auth_google_flutter/serverpod_auth_google_flutter.dart';
import 'auth/login_screen.dart';
import 'game_designer/home_screen.dart';
import 'utils/providers.dart';
void main() {
runApp(const ProviderScope(child: TrivyalApp()));
}
class TrivyalApp extends ConsumerStatefulWidget {
const TrivyalApp({super.key});
@override
ConsumerState<TrivyalApp> createState() => _TrivyalAppState();
}
class _TrivyalAppState extends ConsumerState<TrivyalApp> {
late SessionManager sessionManager;
Future<void>? initSessionFuture;
@override
Widget build(BuildContext context) {
sessionManager = ref.watch(sessionManagerProvider);
initSessionFuture ??= sessionManager.initialize();
return FutureBuilder(
future: initSessionFuture,
builder: (context, sessionInitialized) {
return MaterialApp(
title: 'Trivyal',
theme: ThemeData.from(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: PlatformDispatcher.instance.platformBrightness,
),
),
home: sessionInitialized.connectionState == ConnectionState.done
? sessionManager.isSignedIn
? const HomeScreen()
: const LoginScreen()
: const Scaffold(body: Center(child: CircularProgressIndicator())),
);
},
);
}
}
- Now copy the
LoginScreencode from here totrivyal_flutter/lib/auth/login_screen.dart. This will allow the user to either enter a game pin or sign in with google: - Create
trivyal_flutter/lib/tils/secrets.dartand add your Google Server Client Id that we created earlier:
const String googleServerClientId = '<Your google server client id>';It should something like this <random-numbers>-<random-text>.apps.googleusercontent.com and should match the client id in trivyal_server/config/google_client_secret.json
lib/utils/secrets.dart to your trivyal_flutter/.gitignoreTo compile the app add a home screen at trivyal_flutter/lib/game_designer/home_screen.dart:
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(body: Center(child: Text('This is our home screen!')));
}
}
We will use Railway environment variables to store google client secret
- Encode
google_client_secret.jsonto base64 (It’s a best practice)
base64 -w 0 trivyal_server/config/google_client_secret.json- Copy the output and paste it in a new variable in Railway
CONFIG_GOOGLE_CLIENT_SECRET - Open
Dockerfileand add a step before starting the server to decode the secret to its destination:
...
EXPOSE 8082
ARG CONFIG_GOOGLE_CLIENT_SECRET
RUN echo $CONFIG_GOOGLE_CLIENT_SECRET | base64 -d > ./config/google_client_secret.json
ENTRYPOINT ./server --mode=$runmode --server-id=$serverid --logging=$logging --role=$role- Encode
trivyal_flutter/lib/utils/secrets.dartto base64
base64 -w 0 trivyal_flutter/lib/utils/secrets.dart- Copy the output and paste it in a new variable in Railway
UTILS_SECRETS_DART - Add the decoding command in
Dockerfilebefore the web build:
...
ARG UTILS_SECRETS_DART
RUN echo $UTILS_SECRETS_DART | base64 -d > ./lib/utils/secrets.dart
RUN flutter pub get
RUN flutter build web
...Now let’s deploy to Railway!
- Run
dart bin/main.dart --mode production --apply-migrationsintrivyal_serverdir to apply db migrations

Don’t forget to run your database migration
- Deploy to Railway using
railway up -d
Let’s turn that empty home screen into games list.
This is how our database should be structured:

Trivyal Database structure
So let’s create our first model: Game (we already created User in the previous step)
- Create a new file
trivyal_server/lib/src/models/game.spy.yaml
class: Game
table: games
fields:
name: String
owner: module:auth:UserInfo?, relation(onDelete=cascade)
questions: List<Question>?, relation(name=games_questions)- Add
trivyal_server/lib/src/models/question.spy.yaml
class: Question
table: questions
fields:
gameId: int, relation(name=games_questions,parent=games, onDelete=cascade)
text: String
choices: List<Choice>
correctChoiceId: int
timeInSeconds: int- Add
trivyal_server/lib/src/models/choice.spy.yaml
class: Choice
fields:
id: int
text: String
color: int?- Add
trivyal_server/lib/src/models/game_list_response.spy.yaml
class: GameListResponse
fields:
data: List<Game>- Run
serverpod generateto generate dart models and database mappings - Run
serverpod create-migrationto create db migration
- Create a new file
trivyal_server/lib/src/endpoints/games_endpoint.dart - Copy the code from here and paste it into the file you just created to add a
GamesEndpointclass with the boring CRUD methods - Run
serverpod generateto update the generated code
Let’s change the home screen to show current user games using the GamesEndpoint we created in the previous step
- Add
material_symbols_iconsto dependencies - Change
trivyal_flutter/lib/game_designer/home_screen.dartto match this file - Create a new file
trivyal_flutter/lib/game_designer/edit_game.dart - Copy the code here and paste it into the file you just created to add an
EditGamescreen
This screen creates a new game if the game has null id, otherwise updates the game name
Now let’s add a question editor widget so we can add and edit questions
- Add
collectionandflex_color_pickerto dependencies - Create a new file
trivyal_flutter/ib/game_designer/question_widget.dart - Copy the code here and paste it into the file you just created
If you run the server and the app locally, you should see something like this:

Let’s get the app setup to host live games.
To save live game state, we’ll need to make a new table
- Create a new model
trivyal_server/lib/src/models/live_game.spy.yaml
class: LiveGame
table: live_games
fields:
pin: int
gameId: int, relation(parent=games)
players: List<String>
currentStatus: LiveGameStatus
currentQuestion: Question?
currentQuestionIndex: int?
currentQuestionShowTime: DateTime?
currentResults: Map<String, int>
playerAnswers: Map<String, int>
playerAnswersTime: Map<String, DateTime>
indexes:
pin_idx:
fields: pin
unique: true
gameId_idx:
fields: gameId
unique: true- Add
trivyal_server/lib/src/models/live_game_status.spy.yaml
enum: LiveGameStatus
serialized: byName
values:
- lobby
- starting
- answeringQuestion
- revealingAnswer
- podium
- results- Add
trivyal_server/lib/src/models/live_game_admin_event.spy.yamlfor sending admin events
enum: LiveGameAdminEvent
serialized: byName
values:
- startGame
- showPodium
- nextQuestion- And
trivyal_server/lib/src/models/live_game_answer.spy.yamlfor players to send their answers
class: LiveGameAnswer
fields:
player: String
answerId: int- Run
serverpod generate && serverpod create-migration
Now we will create LiveGames endpoint which will handle game events in realtime and broadcast current state to all clients.

LiveGames endpoint explained
- Create a new endpoint
trivyal_server/lib/src/endpoints/live_games_endpoint.dartand copy the code https://github.com/Andrew-Bekhiet/trivyal/blob/master/trivyal_server/lib/src/endpoints/live_games_endpoint.dart
- Go to
trivyal_flutter/lib/game_designer/home_screen.dartand add a start button to start hosting a live game!
...
return ListView.builder(
...
return ListTile(
title: Text(game.name),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => EditGame(game: game)),
),
trailing: IconButton(
icon: Icon(Symbols.play_circle),
tooltip: 'Start live game',
onPressed: () async {
final navigator = Navigator.of(context);
final streamController = StreamController<LiveGameAdminEvent>();
try {
final liveGameStream = client.liveGames.startOrJoinLiveGame(
gameId: game.id!,
adminMessages: streamController.stream,
);
// TODO: add game shell
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) => GameShell(
// liveGameStream: liveGameStream,
// liveGamePlayerSink: streamController.sink,
// ),
// ),
// );
} catch (_) {
streamController.close();
}
},
),
);
},
);
...Now we’ll setup the gameplay UI.
- Go to
trivyal_flutter/lib/auth/login_screen.dartand add the enter button code:
...
FilledButton(
child: const Text('Enter!'),
onPressed: () {
if (!Form.of(context).validate()) return;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final streamController = StreamController<int>();
try {
final liveGameStream = client.liveGames.joinLiveGame(
pin: int.parse(_pinController.text),
playerName: _nameController.text,
answerIdStream: streamController.stream,
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GameShell(
playerName: _nameController.text,
liveGameStream: liveGameStream,
liveGamePlayerSink: streamController.sink,
),
),
);
} catch (e) {
streamController.close();
if (e.toString().contains('Live game not found')) {
scaffoldMessenger.showSnackBar(SnackBar(
content: Text('Live game not found'),
backgroundColor: Colors.red,
));
}
}
},
);
...This screen will listen to the game stream and show a widget based on current state
- Create a new file
trivyal_flutter/lib/gameplay/game_shelll_screen.dart - Copy the code from here and paste it into the file you just created
This is where players will be waiting until the admin presses start!
- Create a new file
trivyal_flutter/lib/gameplay/lobby_widget.dartand add the following code
import 'package:flutter/material.dart';
import 'package:trivyal_client/trivyal_client.dart';
class LobbyWidget extends StatelessWidget {
final LiveGame liveGame;
const LobbyWidget({required this.liveGame, super.key});
@override
Widget build(BuildContext context) {
if (liveGame.players.isEmpty) {
return const Center(child: Text('Waiting for players to join the game...'));
}
return ListView.builder(
itemCount: liveGame.players.length,
itemBuilder: (context, index) => ListTile(title: Text(liveGame.players[index])),
);
}
}
Just a small 3s countdown animation
- Create a new file
trivyal_flutter/lib/gameplay/live_game_starting_widget.dartand add the following code:
import 'package:flutter/material.dart';
class LiveGameStartingWidget extends StatefulWidget {
const LiveGameStartingWidget({super.key});
@override
State<LiveGameStartingWidget> createState() => _LiveGameStartingWidgetState();
}
class _LiveGameStartingWidgetState extends State<LiveGameStartingWidget> with SingleTickerProviderStateMixin {
late final AnimationController _animationController = AnimationController(vsync: this, duration: Duration(seconds: 3))..forward();
late final Animation<int> countdownAnimation = _animationController.drive(
TweenSequence([
TweenSequenceItem(tween: ConstantTween(3), weight: 1),
TweenSequenceItem(tween: ConstantTween(2), weight: 1),
TweenSequenceItem(tween: ConstantTween(1), weight: 1),
]),
);
late final Animation<double> scaleAnimation = _animationController
.drive(
TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 1),
TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 1),
TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 1),
]),
).drive(CurveTween(curve: Curves.fastOutSlowIn));
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: scaleAnimation.value,
child: Text(
countdownAnimation.value.toString(),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 80),
),
);
},
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}The actual question and answers, and the correct answer when answers are revealed
- Create a new file
trivyal_flutter/lib/gameplay/answering_questions_widget.dart - Copy the code from here and paste it into the file you just created
Animates players’ scores and their ranks so far
- Create a new file
trivyal_flutter/lib/gameplay/podium_widget.dart - Copy the code from here and paste it into the file you just created
Shows the final rank of each player on their screen
- Create
trivyal_flutter/lib/gameplay/rank_widget.dartand add the following code:
import 'package:flutter/material.dart';
class RankWidget extends StatelessWidget {
final int rank;
const RankWidget({super.key, required this.rank});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'You are #',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
rank.toString(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
],
),
);
}
}After finishing the endpoint and the UI code, we’re ready to railway up -d and play!

And that was how to deploy a Flutter/Dart app to Railway!
I hope you enjoyed reading the article as much as I enjoyed writing it!
Here are some helpful resources if you have any trouble.
